Skip to content

Private Key JWT (RFC 7523)

private_key_jwt is the client authentication method used by Craft Easy's M2M flow. Instead of sending a shared secret, the client signs a short-lived JWT assertion with its private key and posts it to the token endpoint. The server verifies the signature against the client's registered public key.

This page covers the assertion format, the token request, server-side validation, and complete code examples.

Why private_key_jwt?

  • No shared secret on the wire. The private key never leaves the client.
  • Replay-proof. Each assertion carries a unique jti that the server caches until expiry.
  • Short-lived. Assertions expire in seconds — a stolen assertion is useless within minutes.
  • Key rotation without downtime. Upload a new public key, switch the client, revoke the old key.

The JWT assertion

The client builds a JWT with these headers and claims:

Field Value Notes
alg ES256 or RS256 Must match the key algorithm registered on the ApiClient.
kid Key ID The key_id returned when the public key was uploaded.
typ JWT Standard JWT type.

Claims

Claim Value Notes
iss client_id The client_id of the ApiClient.
sub client_id Same as iss — the client is asserting its own identity.
aud Token endpoint URL The full URL of the token endpoint, e.g. https://api.example.com/auth/token.
jti Unique ID A UUID or random string. The server rejects any jti it has seen before (replay protection).
iat Current timestamp Unix epoch seconds.
exp iat + TTL Keep this short — 60 seconds is typical. The server rejects assertions older than their exp.

Token request

Send a standard OAuth 2.0 Client Credentials request with the assertion:

POST /auth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIs...

Response (success)

{
  "access_token": "eyJhbGciOiJFUzUxMiJ9...",
  "token_type": "Bearer",
  "expires_in": 1800,
  "scope": "payments:read settlements:create settlements:read"
}
  • access_token — use this as Authorization: Bearer <access_token> on all API calls.
  • expires_in — token lifetime in seconds (default 1800, configurable per client via access_token_ttl_seconds).
  • scope — space-separated list of granted capabilities (the intersection of the client's capabilities and the requested scope).

Response (error)

{
  "error": "invalid_client",
  "error_description": "Client assertion signature verification failed"
}

Standard OAuth 2.0 error codes apply: invalid_client, invalid_grant, unauthorized_client, invalid_scope.

Server-side validation

When the token endpoint receives a request, it performs these steps in order:

  1. Decode the assertion — extract kid from the header and iss/sub from claims.
  2. Look up the ApiClient by client_id = iss. Reject if not found or disabled.
  3. Check IP allowlist — reject if the caller's IP is not in ip_whitelist (when configured).
  4. Check rate limit — reject with 429 if the per-client rate limit is exceeded.
  5. Check lockout — reject with 429 if the client is locked out due to repeated failures.
  6. Find the key by kid among the client's active keys. Reject if not found or revoked.
  7. Verify the signature using the registered public key and the algorithm from the key record.
  8. Validate claimsaud matches the token endpoint URL, exp is in the future, iat is not too far in the past.
  9. Check jti replay cache — reject if the jti has been seen before. Store it with a TTL equal to the assertion's remaining lifetime.
  10. Intersect capabilities — the granted scope is the intersection of the client's capabilities and the requested scope (if any). Reject if the intersection is empty.
  11. Issue the access token — a signed JWT with the client's identity, tenant, and granted capabilities.

Code examples

Python — generate assertion and request token

import time
import uuid
import jwt  # PyJWT
import httpx

# Load your private key
with open("ec-private.pem", "r") as f:
    private_key = f.read()

CLIENT_ID = "batch-worker-a1b2c3"
KEY_ID = "key-1a2b3c"
TOKEN_URL = "https://api.example.com/auth/token"

# Build the JWT assertion
now = int(time.time())
assertion = jwt.encode(
    {
        "iss": CLIENT_ID,
        "sub": CLIENT_ID,
        "aud": TOKEN_URL,
        "jti": str(uuid.uuid4()),
        "iat": now,
        "exp": now + 60,
    },
    private_key,
    algorithm="ES256",
    headers={"kid": KEY_ID},
)

# Request an access token
resp = httpx.post(
    TOKEN_URL,
    data={
        "grant_type": "client_credentials",
        "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
        "client_assertion": assertion,
    },
)
resp.raise_for_status()
token_data = resp.json()

access_token = token_data["access_token"]
print(f"Access token expires in {token_data['expires_in']}s")
print(f"Granted scope: {token_data['scope']}")

Python — use the access token

client = httpx.Client(
    base_url="https://api.example.com",
    headers={"Authorization": f"Bearer {access_token}"},
)

# Call any endpoint within the granted capabilities
settlements = client.get("/settlements?status=pending").json()

cURL — full token request

# Assume the assertion JWT is stored in $ASSERTION
# (In practice, generate it with a script or tool like step-cli)

curl -X POST https://api.example.com/auth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
  -d "client_assertion=${ASSERTION}"

cURL — use the access token

curl https://api.example.com/settlements?status=pending \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"

Security properties

Property How it works
No shared secret Only the public key is stored server-side.
Replay protection The jti claim is cached until the assertion expires. Duplicate jti values are rejected.
Short-lived assertions Assertions should expire within 60 seconds.
Key rotation Upload a new key, switch clients, revoke the old key — no downtime.
Capability intersection Tokens never grant more than the client's registered capabilities.
Rate limiting Per-client rate limits prevent brute-force attempts.
Lockout Repeated failures trigger a temporary lockout.

Token lifetime and re-authentication

Access tokens are not refreshable. When a token nears expiry, the client must generate a new assertion and request a fresh token. This is by design — M2M clients should automate re-authentication rather than maintain long-lived sessions.

A typical pattern is to cache the token and re-authenticate when the token is within 60 seconds of expiry:

import time

_token = None
_token_exp = 0

def get_access_token() -> str:
    global _token, _token_exp
    if _token and time.time() < _token_exp - 60:
        return _token

    token_data = request_new_token()  # as shown above
    _token = token_data["access_token"]
    _token_exp = time.time() + token_data["expires_in"]
    return _token