Skip to content
TDACorpIDENTITYSign in

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 service client. It may hold only the client_credentials and token_exchange grants, authenticates with private_key_jwt, and is linked to a service identity that stands for it. A client without that link is refused with unauthorized_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>
  • scope is 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.
  • resource is not allowed. It is refused with invalid_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_nonce answer 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.

ClaimValue
issThe issuer.
subThe identifier of the service identity behind your client.
audYour own client id.
client_idYour client id.
scopeThe granted scopes, space separated.
cnf{ "jkt": "<thumbprint of your DPoP key>" }.
kindThe kind of identity: a service, not a person.
iat, nbf, exp, jtiTiming 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#

AnswerCause
unauthorized_clientThe client does not hold client_credentials, or has no service identity linked.
invalid_scopeA requested scope is not registered on the client, or the list has an empty entry.
invalid_requestscope appears more than once.
invalid_targetYou sent resource.
invalid_clientClient authentication failed, or no DPoP key was proved.
invalid_grantThe 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_nonceRetry with the DPoP-Nonce value.
429Too many requests. Wait for Retry-After.

Verified against commit ca46b57