Protect your API
Verify identity access tokens and DPoP proofs in your own API with createResourceServer, and where the decision API fits.
On this page
Your API is a resource server. It does two separate jobs, and the SDK treats them separately.
| Job | Question | In the SDK | Status |
|---|---|---|---|
| Verify the token | Was this token issued by identity, for me, and does the caller hold its key? | createResourceServer | Available |
| Decide | May this person do this to this resource? | createIdentity, pepRoute and pep | Available |
Which tokens reach you#
A caller can reach your API with one of two kinds of token, and audience is different for each.
- A person's access token requested with your resource address (
resource, see Scopes and consent). Itsaudis that address. Pass the same address asaudience. - A service token from an exchange (see Service to service). Its
audis your client id. For this to work, your service is a registered client that holds thetoken_exchangegrant. Pass your client id asaudience.
A plain client_credentials token from another service is addressed to that service, so you refuse it. That is correct.
Verify the token#
import { createMemoryDpopReplayStore, createResourceServer, ResourceServerError } from '@tdacorp/identity-sdk'const resourceServer = createResourceServer({ issuer: 'https://identity.tdacorp.in', // The one audience your tokens carry: your client id (exchanged tokens), or your resource address (tokens requested with `resource`). audience: process.env.TDAID_CLIENT_ID ?? '', // The origin callers use to reach you. Never read from the request. publicOrigin: 'https://b.example.com', // Per instance. Supply your own shared store when you run several instances. replay: createMemoryDpopReplayStore(),})export async function GET(request: Request): Promise<Response> { try { const token = await resourceServer.verifyRequest(request) return Response.json({ caller: token.subject }) } catch (error) { if (error instanceof ResourceServerError) return error.response() // 401 with a WWW-Authenticate challenge throw error }}createResourceServer refuses the request unless all of these hold.
- The signature verifies against the issuer's published keys, and the algorithm is on the allow-list (
EdDSAandES256by default, limited to what the issuer publishes a key for; anything else is refused when you create the server). issequals the issuer exactly and thetypheader isat+jwt.expminusiatis at most 900 seconds.- There is exactly one
aud, and it is youraudience. - By default (
requireConfirmation: true) the token carriescnf. A token bound withcnf.jktmust arrive under theDPoPscheme, with a proof whosehtm,htu,iat(within 60 seconds),ath, key thumbprint andjtiall check out. ABearerlabel on a bound token is refused, and so is theDPoPscheme on an unbound one.
The proof's htu is rebuilt from the publicOrigin you configure plus the request path. The Host header and X-Forwarded-* are never read. audience equal to the issuer, an empty audience and a missing replay store fail when you construct the server.
A refusal is always a 401 with a WWW-Authenticate challenge and a body of {"error":"invalid_token"} or {"error":"invalid_dpop_proof"} (or {"error":"insufficient_user_authentication"}, see below). It names no claim and never contains the token. ResourceServerError.response() builds it.
createResourceServer verifies. It does not check scopes. Read token.scope yourself if a route needs one. The verified token also gives you subject (the token's sub, verbatim: an access token always carries the person's real sub, even when the client that asked for it is registered pairwise, so do not compare it with that client's ID token sub), tenantId, sessionId, scope, kind and, for an exchanged token, actor.
Ask for a fresher or stronger sign-in#
Pass requireAuthentication: { acrValues?, maxAgeS? } to createResourceServer, or to an adapter such as pep, for a route that needs a recent or strong authentication (RFC 9470). A token that verifies but whose acr does not reach acrValues, or whose auth_time is older than maxAgeS seconds or missing, is answered with a 401 whose challenge is insufficient_user_authentication and carries the acr_values and max_age it wants. Your client answers it with rp.stepUpUrl(challenge, tenant), which starts a new authorization asking for exactly that. See Resource-server verification and Relying-party module.
The replay store#
A DPoP proof may be used once. createMemoryDpopReplayStore remembers proofs for 120 seconds, holds 50,000 of them, and is per instance. Two consequences:
- On a platform where every request may reach a different instance, a proof captured off one instance can be replayed once against another inside its 60 second window. Implement
DpopReplayStoreover a shared table if that matters to you. - A full store refuses new proofs rather than evicting a live one. A caller with a valid token could fill it on purpose, so put a rate limit in front of the resource server, or use a shared store.
Decide what the caller may do#
Identity can answer authorization questions: may this person perform this action on this resource. The SDK has a client for it, createIdentity, and adapters built on it: pepRoute and pepMiddleware for Next.js, pep for Hono and Express. They send the token's sub and an action such as eidos.document.read, and return ALLOW, DENY or INDETERMINATE. They never produce an ALLOW that identity did not send.
Status: Available. The decision client authenticates to identity as your product with a service client: pass headers: pdpCredential(service) to createIdentity. See Decision client for the example and what your product needs first. Where a route needs more than a scope, call the decision client after you verify the token.
What to build today#
- Verify tokens with
createResourceServer. - Check
token.scopewhere a route needs a scope. - Where a route needs more than a scope, call the decision client after you verify the token: Decision client.
A worked example with Express middleware is in Protect an API in Node and Express.