ASVS 5.0 V10.4.16 (Level 3) requires the client to be confidential and the authorization server to demand strong client authentication: methods based on public-key cryptography that resist replay. The standard names mutual TLS (tls_client_auth and self_signed_tls_client_auth) and private_key_jwt.
A shared secret sent on every request is the thing this requirement moves away from. If anyone copies it, they can impersonate the client until it is rotated.
Why it matters
With a private key, the secret never leaves the client. The server stores only the public key, so a database leak at the authorization server does not expose usable credentials. Assertions are signed per request and short-lived, so a captured one cannot be replayed for long.
How to implement it
With private_key_jwt, the client signs a JWT assertion for each token request.
import time, uuid, jwt # PyJWT
def client_assertion(client_id, token_url, private_key, kid):
now = int(time.time())
return jwt.encode(
{"iss": client_id, "sub": client_id, "aud": token_url,
"iat": now, "exp": now + 60, "jti": str(uuid.uuid4())},
private_key, algorithm="ES256", headers={"kid": kid})
The request carries client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and the client_assertion.
The server must verify the signature with the registered public key, check iss, sub and aud, enforce a short exp, and reject a jti it has already seen. Accept only the algorithms you intend, never none.
For mutual TLS, register the certificate or its subject and verify it during the handshake.
Key management for the client
The client's private key needs the same care as any signing key. Generate it in a hardware module or a cloud key management service when you can, so it never exists as a file. Give each environment its own key. Publish the public part as a JWKS endpoint or register it directly, and rotate on a schedule by adding the new key before retiring the old one. Use a kid header so the server knows which to check.
Server side validation checklist
When an assertion arrives, the server should check each of these in order: the algorithm is on the allow list, the key matches the registered one for that client, the signature verifies, iss and sub equal the client ID, aud equals the token endpoint URL or issuer, exp is soon and iat is recent, and the jti has not been seen. Only then does it proceed with the grant. Failing any one returns invalid_client.
Mutual TLS in practice
With mutual TLS, certificate handling becomes the main task. Decide whether you trust a certificate authority (tls_client_auth) or pin self-signed certificates (self_signed_tls_client_auth). Make sure any proxy or load balancer in front of the server passes the verified client certificate through a channel the outside world cannot spoof.
How to test it
Send a token request with a client secret instead of an assertion and expect rejection. Send an expired assertion, one with the wrong audience, one signed with another key, and one reusing a jti. All must fail. A correct assertion should succeed.
Migration path
Start by listing every confidential client and its current authentication method. Move internal services first, since you control both sides. Run both methods side by side per client during the change, then disable the shared secret for each client once its traffic shows only assertions. Finally, remove the secret method from the server's supported list.
Common mistakes
- Leaving
client_secret_basicenabled as a fallback. - Not checking the audience, which allows assertions meant for another server.
- Skipping
jtireplay tracking.
Quick check
What does the authorization server store with private_key_jwt?
Only the client's public key, so a leak of the server's data does not expose a usable credential.