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
jtithat 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:
Header¶
| 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 asAuthorization: Bearer <access_token>on all API calls.expires_in— token lifetime in seconds (default 1800, configurable per client viaaccess_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:
- Decode the assertion — extract
kidfrom the header andiss/subfrom claims. - Look up the ApiClient by
client_id=iss. Reject if not found or disabled. - Check IP allowlist — reject if the caller's IP is not in
ip_whitelist(when configured). - Check rate limit — reject with
429if the per-client rate limit is exceeded. - Check lockout — reject with
429if the client is locked out due to repeated failures. - Find the key by
kidamong the client's active keys. Reject if not found or revoked. - Verify the signature using the registered public key and the algorithm from the key record.
- Validate claims —
audmatches the token endpoint URL,expis in the future,iatis not too far in the past. - Check
jtireplay cache — reject if thejtihas been seen before. Store it with a TTL equal to the assertion's remaining lifetime. - Intersect capabilities — the granted scope is the intersection of the client's
capabilitiesand the requestedscope(if any). Reject if the intersection is empty. - 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