Protect a backend API
This tutorial accepts an Identity access token at a product API and turns it into a product authorization context. Token validation proves the caller’s Identity subject; the product backend still decides which business objects and operations that subject may access.
Before you start
Register the API as an Identity resource with:
- a stable resource URI, for example
https://api.example.com; - the audience expected in access tokens, for example
example-api; - finite scopes such as
orders.readandorders.refund.
Authorize the calling OAuth client for that resource and its required scopes. Issue a resource-server credential only if this API will call token introspection.
1. Read discovery and signing keys
ISSUER='https://auth.example.com'
curl --fail --silent --show-error \
"$ISSUER/.well-known/openid-configuration"Cache the discovery document for the configured issuer. Fetch keys from its jwks_uri and cache them according to HTTP response semantics. Refresh the key set when a token carries an unknown kid.
Never derive a tenant from an unverified token and then trust that tenant’s issuer. Select the configured issuer from the API’s own routing or tenant configuration first.
2. Parse the bearer credential
Accept one authorization header in this form:
Authorization: Bearer eyJraWQiOiJrZXktMjAyNi0xMCIsImFsZyI6IlJTMjU2In0...Return 401 when the header is absent, duplicated, malformed or uses another scheme. Do not log the credential.
3. Validate the JWT completely
A successful signature check is only one condition. Require all of these invariants:
| Check | Required value |
|---|---|
| signature algorithm | RS256 from the API’s allowlist |
| signing key | public key matching the header kid |
| issuer | exact configured tenant issuer |
| audience | the resource’s registered audience |
| expiry | exp is in the future within bounded clock skew |
| issued time | iat is plausible within bounded clock skew |
| subject | non-empty sub |
| token identity | non-empty jti |
| scopes | every scope required by the operation |
Reject an ID token at the API even if its signature is valid. The API accepts access tokens issued for its own audience.
A useful authorization result is a closed value owned by the API:
ValidatedIdentity {
issuer
subject
clientId
scopes
tokenId
expiresAt
}Create it only after every token invariant succeeds.
4. Add live introspection when the operation needs it
Local JWT validation remains valid until token expiry. For high-risk operations that must observe revocation or current account status immediately, call the tenant’s discovered introspection endpoint.
ISSUER='https://auth.example.com'
curl --fail --silent --show-error \
--user 'resource-server-id:RESOURCE_SERVER_SECRET' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'token=ACCESS_TOKEN' \
"$ISSUER/oauth2/introspect"Require active: true, then repeat the issuer, audience and scope checks against the response. Treat 200 {"active":false} as an unauthenticated request.
An introspection credential belongs to one registered resource. Keep it in the API’s secret manager and never ship it to a browser or native application.
Do not introspect every request by accident. Choose the policy explicitly:
- local validation for normal bounded-lifetime API access;
- introspection for sensitive actions or when immediate revocation is a product requirement;
- a short, bounded introspection cache only if the accepted revocation delay is documented.
5. Resolve the product identity
Store an explicit association such as:
external_identities
issuer = "https://auth.example.com"
subject = "usr_01JY7N8SV4B30TQGB8Y3YH5J9M"
user_id = 42Resolve by exact issuer and subject. Then load the user’s product organizations, memberships, roles and permissions.
A first-time subject can trigger explicit just-in-time provisioning when the product allows it. Linking an existing product user requires a migration or account-linking proof. Email matching alone is insufficient.
6. Enforce scope and business permission separately
For an order refund, the request might require:
- token scope
orders.refund; - an active product membership in the order’s organization;
- the product permission to refund that order;
- any business constraint such as refund window or order state.
Return 401 when authentication failed. Return 403 when the authenticated subject lacks scope or product permission. Avoid returning 404 as a generic substitute unless hiding resource existence is an explicit product policy.
Verify the finished use case
Call one protected endpoint with:
- no token: expect
401; - an ID token: expect
401; - an access token for another audience: expect
401; - a valid token without the operation scope: expect
403; - a valid token and scope but no product membership: expect
403; - the correct token and product permission: expect the business response;
- a revoked token on an introspected action: expect
401.
Also test signing-key overlap during rotation: both the retiring key and new active key must validate until old tokens can no longer be valid.
See token response and JWKS contracts, resource-server invariants and token handling.