Client credentials
How a service gets an access token for itself, with no person involved, and what the token looks like.
On this page
The client_credentials grant is for a service that acts as itself. There is no browser, no sign-in and no person. The service proves who it is with its key, and identity gives it an access token that says so. If the token must be accepted by another service, the first service exchanges it for one addressed to that service: see Token exchange.
What you need#
- A
serviceclient. It may hold only theclient_credentialsandtoken_exchangegrants, authenticates withprivate_key_jwt, and is linked to a service identity that stands for it. A client without that link is refused withunauthorized_client. - A DPoP key. Tokens for a service are always bound to a key, whatever the registration says: a service client that proves no key at the token endpoint is refused with
invalid_client. - The scopes you will ask for, registered on the client. See Get a client registered.
The request#
POST /tokenContent-Type: application/x-www-form-urlencodedDPoP: <proof>grant_type=client_credentials&client_id=<client id>&scope=<optional, space separated>&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=<assertion>scopeis optional, may appear once, and must be a subset of the client's registered scopes. If you leave it out, you receive every scope the client is registered for. Ask for only what you need.resourceis not allowed. It is refused withinvalid_target. The token is addressed to the client itself.- The client assertion is described in Client authentication, and the proof in DPoP. Expect a
use_dpop_nonceanswer on the first request, and retry once with the nonce and a new assertion.
The answer#
{ "access_token": "<jwt>", "token_type": "DPoP", "expires_in": 600, "scope": "<granted scopes>"}There is no refresh token and no ID token. When the token expires, ask for another. The lifetime is the client's access token setting, 600 seconds unless changed, and never more than 900.
Decoded, the access token has typ at+jwt in the header and, among others, these claims.
| Claim | Value |
|---|---|
iss | The issuer. |
sub | The identifier of the service identity behind your client. |
aud | Your own client id. |
client_id | Your client id. |
scope | The granted scopes, space separated. |
cnf | { "jkt": "<thumbprint of your DPoP key>" }. |
kind | The kind of identity: a service, not a person. |
iat, nbf, exp, jti | Timing and a unique id. |
Because aud is your own client id, no other service accepts this token. That is deliberate. A service that receives a token addressed to somebody else must refuse it, so a token cannot be passed along and replayed against a third service.
Cache it, but not for long#
A token is good for several minutes. Cache it, share one in-flight request between concurrent callers, and fetch a new one shortly before it expires, not when a call has already failed. The SDK's service client does all of that and replaces a cached token 30 seconds early by default.
import { createServiceClient } from '@tdacorp/identity-sdk/service'import { importDpopKeyPair } from '@tdacorp/identity-sdk/rp'const clientKey = JSON.parse(process.env.TDAID_CLIENT_KEY_JWK ?? '{}')const dpopKey = JSON.parse(process.env.TDAID_DPOP_KEY_JWK ?? '{}')const service = createServiceClient({ issuer: 'https://identity.tdacorp.in', clientId: process.env.TDAID_CLIENT_ID ?? '', key: { kid: process.env.TDAID_CLIENT_KID ?? '', privateKey: clientKey }, dpop: await importDpopKeyPair(dpopKey),})// Calls service B. `audience` is B's client id.export async function callServiceB(audienceClientId: string): Promise<Response> { const token = await service.tokenFor(audienceClientId) // client_credentials, then an RFC 8693 exchange; cached const url = 'https://b.example.com/things' return fetch(url, { headers: await service.headersFor(token, { method: 'GET', url }) })}tokenFor(audience) with your own client id or 'self' returns this token. With another client id as the audience it takes the second step for you.
What can go wrong#
| Answer | Cause |
|---|---|
unauthorized_client | The client does not hold client_credentials, or has no service identity linked. |
invalid_scope | A requested scope is not registered on the client, or the list has an empty entry. |
invalid_request | scope appears more than once. |
invalid_target | You sent resource. |
invalid_client | Client authentication failed, or no DPoP key was proved. |
invalid_grant | The service identity behind the client is not active, or the client's app is no longer installed in the organization it belongs to. |
use_dpop_nonce | Retry with the DPoP-Nonce value. |
| 429 | Too many requests. Wait for Retry-After. |
Related#
- Service to service: both steps together, with the registration each side needs.
- Protect your API: what the receiving side verifies.