OAuth2 private key JWT, usually written as private_key_jwt, is a client authentication method for confidential OAuth clients. Instead of sending a shared client_secret to the token endpoint, the client signs a short-lived JWT with its private key. The authorization server verifies the signature with the client's registered public key, checks the claims, and then decides whether to issue tokens.
This pattern is common in high-trust server-to-server systems, financial APIs, enterprise OIDC integrations, partner APIs, and internal platforms where a copied client secret would be too easy to leak. It does not remove all key management. It changes the shared-secret problem into an asymmetric-key problem with short-lived assertions, key identifiers, rotation, and replay protection.
What Private Key JWT Solves
Most OAuth client authentication starts with client_secret_basic or client_secret_post. Both use a symmetric secret. The client and authorization server know the same value. That is simple, but it has production problems:
- The secret is reusable. If it leaks from logs, CI variables, support bundles, Terraform state, or a developer machine, the attacker can authenticate until it is rotated.
- The secret must be present at runtime. Every environment that calls the token endpoint needs a copy or a secret-manager reference.
- Rotation is disruptive. Changing a shared secret often means coordinating all deployments that use it.
- Attribution is weak. The token endpoint sees the client, but not a one-time assertion identifier that can be replay-checked and audited.
With private_key_jwt, the authorization server stores or fetches only the public key. The private key stays with the client. Each token request includes a freshly signed JWT assertion with a short expiration and a unique jti. The server verifies the assertion and rejects reused, expired, wrong-audience, or wrongly signed assertions.
The Core Flow
Owns private key
JWKS or jwks_uri
iss, sub, aud, exp, jti
Verifies signature and claims
Issued only after policy
- Generate a key pair. The client keeps the private key and exposes or uploads the public key as JWK/JWKS.
- Register the client. The authorization server records
client_id, allowed grant types, allowed scopes,token_endpoint_auth_method=private_key_jwt, allowed signing algorithms, and the public key. - Create a client assertion. For each token request, the client builds a JWT whose issuer and subject identify the client, whose audience identifies the token endpoint, and whose expiration is close.
- Sign the assertion. The JWT header includes an allowed
algand akidthat selects the public key. - POST to the token endpoint. The request includes
client_assertion_typeandclient_assertionalong with the normal OAuth grant parameters. - Verify and issue. The authorization server verifies the signature and claims, rejects replayed
jtivalues, checks policy, and returns an access token if the request is allowed.
Where It Fits in OAuth2
private_key_jwt is about client authentication. It answers: "Is this OAuth client really the registered confidential client?" It is not the same thing as a JWT access token, an ID token, or a JWT bearer authorization grant.
| Term | Purpose | Common confusion |
|---|---|---|
private_key_jwt |
Authenticates the OAuth client to the token endpoint using a signed JWT assertion. | The assertion proves the client, not the end user. |
| JWT access token | Represents authorization to call a resource server. | A private key JWT assertion is sent to the authorization server, not to the API as a bearer token. |
| OpenID Connect ID token | Represents authentication information about an end user. | An ID token is not client authentication. |
| JWT bearer grant | Uses a JWT as an authorization grant to request an access token. | RFC 7523 defines both JWT grants and JWT client authentication, but they are different roles. |
| mTLS client authentication | Authenticates the client at the TLS layer and can bind tokens to certificates. | mTLS and private_key_jwt both use asymmetric keys, but at different protocol layers. |
The Token Request
For client credentials, the request shape looks like this:
POST /oauth2/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&scope=payments.read payments.write
&client_id=orders-service
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6Im9yZGVycy0yMDI2LTA3In0...
The value of client_assertion_type is fixed for JWT bearer assertions: urn:ietf:params:oauth:client-assertion-type:jwt-bearer. The client_assertion is one compact JWS: header, payload, signature.
JWT Claims You Must Get Right
The assertion is small, but every claim matters. A valid signature alone is not enough. The authorization server must know which client signed the assertion, which endpoint it was meant for, when it expires, and whether it has been used before.
| Field | Example | Production rule |
|---|---|---|
Header alg |
RS256 or ES256 |
Allowlist algorithms per client. Never accept none or whatever the token asks for. |
Header kid |
orders-2026-07 |
Selects the registered public key. Required for smooth rotation. |
iss |
orders-service |
For client authentication, normally the OAuth client_id. |
sub |
orders-service |
For client authentication, must identify the same client. Do not accept a user subject here. |
aud |
https://auth.example.com/oauth2/token |
Must identify the authorization server or token endpoint exactly as configured. |
iat |
1782902400 |
Issued-at time. Reject assertions too far in the past or future. |
exp |
1782902700 |
Keep short. Five minutes or less is a common starting point. |
jti |
uuid |
Unique assertion ID. Store until expiration and reject replay. |
Build It in Python
The demo below is self-contained. It generates an RSA key pair, publishes the public key as a JWK, signs a private_key_jwt assertion, verifies it like an authorization server would, rejects a replay, and prints the form body you would send to a real token endpoint.
Install Dependencies
python -m venv .venv
.venv\Scripts\activate
pip install pyjwt cryptography requests
On macOS or Linux, activate the environment with source .venv/bin/activate.
private_key_jwt_demo.py
import base64
import json
import time
import uuid
from dataclasses import dataclass, field
from typing import Dict, Set
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
CLIENT_ASSERTION_TYPE = (
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
)
def b64url_uint(value: int) -> str:
"""Encode an RSA integer as base64url without padding for JWK."""
byte_length = (value.bit_length() + 7) // 8
data = value.to_bytes(byte_length, "big")
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def generate_rsa_key_pair():
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
return private_key, private_pem
def public_jwk(private_key, kid: str) -> Dict[str, str]:
numbers = private_key.public_key().public_numbers()
return {
"kty": "RSA",
"kid": kid,
"use": "sig",
"alg": "RS256",
"n": b64url_uint(numbers.n),
"e": b64url_uint(numbers.e),
}
def create_client_assertion(
*,
client_id: str,
token_endpoint: str,
private_pem: bytes,
kid: str,
lifetime_seconds: int = 300,
) -> str:
now = int(time.time())
claims = {
"iss": client_id,
"sub": client_id,
"aud": token_endpoint,
"iat": now,
"exp": now + lifetime_seconds,
"jti": str(uuid.uuid4()),
}
headers = {
"alg": "RS256",
"kid": kid,
"typ": "JWT",
}
return jwt.encode(
claims,
private_pem,
algorithm="RS256",
headers=headers,
)
@dataclass
class RegisteredClient:
client_id: str
token_endpoint: str
jwks: Dict[str, Dict[str, str]]
allowed_algs: Set[str] = field(default_factory=lambda: {"RS256"})
class AuthorizationServerVerifier:
def __init__(self):
self.used_jti: Set[str] = set()
def verify_private_key_jwt(
self,
*,
client: RegisteredClient,
assertion: str,
) -> Dict:
header = jwt.get_unverified_header(assertion)
alg = header.get("alg")
kid = header.get("kid")
if alg not in client.allowed_algs:
raise ValueError(f"unsupported alg: {alg}")
if not kid:
raise ValueError("missing kid")
if kid not in client.jwks:
raise ValueError(f"unknown kid: {kid}")
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(
json.dumps(client.jwks[kid])
)
claims = jwt.decode(
assertion,
public_key,
algorithms=[alg],
audience=client.token_endpoint,
issuer=client.client_id,
options={
"require": ["iss", "sub", "aud", "exp", "iat", "jti"],
},
leeway=30,
)
if claims["sub"] != client.client_id:
raise ValueError("sub must equal client_id")
if claims["jti"] in self.used_jti:
raise ValueError("replayed client assertion")
self.used_jti.add(claims["jti"])
return claims
def main():
client_id = "orders-service"
token_endpoint = "https://auth.example.com/oauth2/token"
kid = "orders-2026-07"
private_key, private_pem = generate_rsa_key_pair()
jwk = public_jwk(private_key, kid)
registered_client = RegisteredClient(
client_id=client_id,
token_endpoint=token_endpoint,
jwks={kid: jwk},
)
assertion = create_client_assertion(
client_id=client_id,
token_endpoint=token_endpoint,
private_pem=private_pem,
kid=kid,
)
verifier = AuthorizationServerVerifier()
claims = verifier.verify_private_key_jwt(
client=registered_client,
assertion=assertion,
)
print("verified client assertion")
print(json.dumps(claims, indent=2))
token_request_form = {
"grant_type": "client_credentials",
"scope": "payments.read",
"client_id": client_id,
"client_assertion_type": CLIENT_ASSERTION_TYPE,
"client_assertion": assertion,
}
print()
print("form fields for the token endpoint:")
for key, value in token_request_form.items():
display = value if key != "client_assertion" else value[:80] + "..."
print(f"{key}={display}")
try:
verifier.verify_private_key_jwt(
client=registered_client,
assertion=assertion,
)
except ValueError as exc:
print()
print(f"replay rejected: {exc}")
if __name__ == "__main__":
main()
Run It
python private_key_jwt_demo.py
You should see the decoded claims, the token endpoint form fields, and a replay rejection for the second verification attempt. In a real client, the client sends the form fields to the authorization server over HTTPS. In a real authorization server, the verifier loads the registered public key from client metadata, a database, or a trusted jwks_uri.
How to Send the Assertion to a Real Token Endpoint
If you already have a provider that supports private_key_jwt, the client code changes only at the final step. Instead of verifying locally, create the assertion and POST it:
import requests
response = requests.post(
"https://auth.example.com/oauth2/token",
data={
"grant_type": "client_credentials",
"scope": "payments.read",
"client_id": "orders-service",
"client_assertion_type": CLIENT_ASSERTION_TYPE,
"client_assertion": assertion,
},
timeout=10,
)
response.raise_for_status()
tokens = response.json()
print(tokens["access_token"])
Do not log the assertion or access token. The assertion is short-lived, but it is still a bearer artifact during its lifetime.
Registering the Public Key
The authorization server needs a trusted public key for the client. There are two common registration patterns:
- Static JWKS: upload the public JWK during client registration. This is simple for a small number of clients.
jwks_uri: register an HTTPS URL where the authorization server can fetch the client's JWKS. This is better for larger systems and key rotation, but it requires strict HTTPS, hostname validation, caching, and SSRF protections.
A public JWKS for the demo key looks like this:
{
"keys": [
{
"kty": "RSA",
"kid": "orders-2026-07",
"use": "sig",
"alg": "RS256",
"n": "base64url-modulus",
"e": "AQAB"
}
]
}
Only publish public key material. Never publish a private JWK that contains fields such as d, p, q, dp, dq, or qi.
Key Rotation Strategy
Key rotation works cleanly when every key has a stable kid and the server can accept more than one valid public key during the transition.
- Create a new key pair. Keep the old private key active.
- Publish the new public key. Add it to JWKS with a new
kid. - Wait for caches. Give authorization servers enough time to refresh JWKS.
- Start signing with the new key. The JWT header now uses the new
kid. - Remove the old key after its last possible assertion expires. Include cache TTL and clock skew in the wait time.
Verifier Rules for Authorization Servers
If you implement the authorization server side, the verifier should fail closed and be boring. The checks are not optional polish; they are the security boundary.
- Read
client_idfrom the request or derive it from the assertion only after verification policy is clear. Do not let an untrusted token choose arbitrary client metadata without bounds. - Require a registered authentication method. The client must be configured for
private_key_jwt. - Allowlist algorithms per client. Do not choose verification behavior solely from the JWT header.
- Select only registered keys. Match
kidto a public key registered for that client. - Require
issandsub. For client authentication, both should identify the OAuth client. - Require exact
aud. Prefer the token endpoint URL or the exact issuer value your server documents. - Require short
exp. Reject expired assertions and assertions too far in the future. - Check
iat. Reject assertions that are too old or too far ahead of server time. - Replay-check
jti. Store the value until expiration and reject repeats. - Return
invalid_clientsafely. Give enough detail in internal logs, but avoid leaking key lookup and policy internals to callers.
Common Failure Modes
| Symptom | Likely cause | Fix |
|---|---|---|
invalid_client with no detail |
Wrong signing key, wrong client method, missing kid, or issuer mismatch. |
Compare the registered client metadata with the assertion header and claims. |
| Audience validation failed | aud is issuer URL but provider expects token endpoint URL, or the reverse. |
Use the exact value documented by your authorization server. |
| Works once, then fails | The same assertion is being reused and replay protection is active. | Create a fresh assertion with a new jti for every request. |
| Fails during rotation | New kid not published, JWKS cache has not refreshed, or old key removed too early. |
Overlap old and new keys, respect JWKS cache TTL, and publish before signing. |
| Clock-related failures | Client and server clocks drifted or the assertion lifetime is too tight. | Use NTP, allow small skew, and keep lifetime short but practical. |
| Algorithm errors | The client signs with an unsupported algorithm. | Read authorization server metadata and configure one allowed algorithm explicitly. |
Security Checklist
- Use a hardware-backed key store or managed KMS/HSM when the client is high value.
- Never commit private keys, private JWKs, generated assertions, or access tokens.
- Use one client and key set per environment. Do not share production client keys with staging.
- Keep assertion lifetimes short and require a unique
jti. - Pin acceptable algorithms. Avoid algorithm agility unless it is explicitly configured.
- Use separate keys for signing client assertions and other JWT types.
- Log
client_id,kid,jti, decision, grant type, and correlation ID. Do not log full JWTs. - Alert on sudden
invalid_clientspikes, unknownkidvalues, replay attempts, and token requests outside normal regions or networks. - Protect
jwks_urifetching from SSRF. Fetch only registered HTTPS URLs and never follow them to internal metadata services. - Document break-glass rotation: who can disable a key, add a new key, and revoke old tokens.
When to Choose Private Key JWT
Use private_key_jwt when the client is a confidential server-side application, the authorization server supports it, and you want to avoid long-lived shared client secrets. It fits partner APIs, SaaS enterprise integrations, platform services, background jobs, and backend services that already have a secure place for private keys.
Use mTLS when you need certificate-based client authentication at the transport layer or certificate-bound tokens. Use OIDC workload federation when the runtime platform can prove workload identity and you want to remove manually managed keys from CI, Kubernetes, or cloud workloads. Use a client secret only when the risk is acceptable and rotation is simple.
Related CodersSecret Guides
- OAuth 2.0 and OpenID Connect: A Developer Guide
- OIDC Workload Federation: Build Secretless Service Access
- M2M Authentication: Securing Service-to-Service Communication
- MCP Security for Production AI Agents
- API Security Attacks and Defense Guide
- Centralized Authentication and Authorization with Envoy
Sources and Further Reading
- RFC 7523: JSON Web Token Profile for OAuth 2.0 Client Authentication and Authorization Grants
- OpenID Connect Core 1.0: Client Authentication
- RFC 8414: OAuth 2.0 Authorization Server Metadata
- RFC 7515: JSON Web Signature
- RFC 7517: JSON Web Key
- RFC 8725: JSON Web Token Best Current Practices
- RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens