Client authentication
How a client proves who it is to identity with a signed assertion, every claim the assertion needs, and what makes one fail.
On this page
Every call your server makes to the pushed authorization, token, revocation and introspection endpoints must say which client it is and prove it. Identity has no client secrets. A client proves itself by signing a short JWT, called a client assertion, with a private key that only it holds. Identity checks the signature against the public key registered for the client.
This removes the shared secret from the picture: nothing identity stores can be used to impersonate you, and nothing you send can be replayed.
Methods#
The discovery document lists them in token_endpoint_auth_methods_supported.
| Method | For | How |
|---|---|---|
private_key_jwt | web and service clients | A signed assertion in the request body. The default, and the one this page describes. |
none | Public clients (spa, native) | No credential. The client must prove a DPoP key instead, and identity requires it. Status: In development, see Choosing an integration. |
tls_client_auth | Clients that hold a certificate | In development. |
A service client may not use none. A client registered with none and without DPoP is refused at registration, because it would present no authentication of any kind.
The assertion#
An assertion is a compact JWT. Send it as two form fields next to client_id:
client_id=<client id>&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=<the JWT>Each of client_assertion_type and client_assertion must appear exactly once. The assertion must be at most 8192 characters.
| Part | Value |
|---|---|
Header alg | EdDSA or ES256, matching your key. |
Header kid | The key id you registered. |
iss | Your client id. |
sub | Your client id, the same value. |
aud | The issuer, as one string: https://identity.tdacorp.in. Not the endpoint address, and not an array. |
exp | At most 300 seconds from now (a deployment can lower that ceiling, to no less than 60). The SDK uses 60 by default. |
jti | A fresh unique value, at most 255 characters. |
iat | Now. |
Identity looks up the key by kid among the live keys of that client. A key that is revoked, expired or unknown is refused. It then verifies the signature with the algorithm in the header, iss, sub, aud and exp.
One assertion, one request#
Identity records every jti it accepts and refuses a second use with invalid_client. That has a consequence people trip over: when a request is answered with use_dpop_nonce and you retry, the retry needs a new assertion with a new jti, because the first one was already spent. The SDK rebuilds the whole request on that retry. If you build requests yourself, generate the assertion as the last step before sending, every time.
Lifetime and clocks#
An assertion whose exp is already past is refused, and so is one whose exp is more than 300 seconds ahead of identity's clock. A server whose clock is a few minutes wrong therefore fails here first, with invalid_client and no other clue. See Clock skew.
What fails, and how it looks#
All failures are invalid_client and are meant to be indistinguishable from outside beyond a short description. At the token, revocation and introspection endpoints the status is 401. At the pushed authorization endpoint it is 400. Read error, not the status.
| Cause | Description (abridged) |
|---|---|
| Missing, repeated or wrong-typed assertion fields | client_assertion and client_assertion_type are each required exactly once or a type message |
| Not a JWT, or an algorithm identity does not use | the client assertion is not a decodable JWS or the client assertion alg is not one this server supports |
No kid in the header | the client assertion header carries no kid |
| Unknown, revoked or expired key | no live registered key matches the assertion's kid |
Bad signature, wrong iss, sub or aud, expired | the client assertion does not verify |
aud is an array | the client assertion aud must be the issuer as a single string |
exp too far ahead | a client assertion may not live longer than 300 seconds |
jti or exp missing | the client assertion does not verify |
jti empty or too long | the client assertion carries no usable jti |
jti seen before | this client assertion has already been presented |
| Client suspended | this client is suspended |
Client authentication is checked before the DPoP proof. A request with a bad assertion never reaches the proof check.
Keys#
- Ed25519 or P-256. Anything else is refused at registration.
- Client metadata documents. When the
oauth_cimdflag is on (it defaults OFF), a client whoseclient_idis anhttpsURL publishes its public keys in that document, and may use onlyprivate_key_jwtornone. No client secret is accepted there. - Public half only. You send identity the public JWK and a
kid. The private key never leaves your infrastructure. - Rotation. A client may hold several keys. Register the new key, switch your application to sign with it, then ask for the old key to be revoked. See Get a client registered.
- Compromise. Ask for the key to be revoked. A revoked key stops working at once, and since it was only ever a signing key, nothing else needs to be rotated on identity's side.
With the SDK#
Pass key: { kid, privateKey } to createRelyingParty or createServiceClient. The key is a private JWK or a CryptoKey. lifetimeSeconds sets the assertion lifetime, a whole number from 1 to 300. The SDK builds a new assertion for every request.
Without the SDK#
Signing an assertion needs nothing but your language's crypto library. The Raw HTTP walkthrough includes a small script that builds assertions with only Node's built-in crypto module, and shows where each one goes in a request.
Related#
- DPoP: the second proof a client usually sends alongside this one.
- Error handling.