Resource-server verification
Verify an access token and its DPoP proof in your own API with createResourceServer, and what it returns or refuses.
On this page
createResourceServer is the SDK's verifier for an API. It answers one question about a request: was this access token issued by identity, for this API, and does the caller hold the key it is bound to? It does not decide what the caller may do; that is a separate step, covered in Decision client.
It is imported from the SDK's root entry. The checks it makes are the ones in Access token and DPoP verification; the guide is Protect your API.
Create it#
import { ResourceServerError, createMemoryDpopReplayStore, createResourceServer } from '@tdacorp/identity-sdk'const resourceServer = createResourceServer({ issuer: 'https://identity.tdacorp.in', audience: 'https://api.example.com', publicOrigin: 'https://api.example.com', replay: createMemoryDpopReplayStore(),})| Option | Default | Meaning |
|---|---|---|
issuer | required | The issuer you trust. |
audience | required | The one value your tokens carry in aud: your resource address, or your client id for exchanged tokens. Not empty, and not the issuer. |
publicOrigin | required | The origin callers use to reach you, a bare https origin (http for loopback). Used to rebuild the expected proof address. The request's Host and X-Forwarded-* are never read. |
replay | required | A DPoP replay store. Construction fails without one. |
requireConfirmation | true | Require cnf on every token. Set false only if you accept unbound tokens. |
jwks | the issuer's key set | A key resolver. By default the issuer's published keys are fetched remotely. |
algorithms | ['EdDSA', 'ES256'] | The signing algorithms you accept. Only these two are ever allowed: none, HS256, RS256 and anything else are refused with a RangeError when the verifier is created. A token still verifies only against a key the issuer publishes for its algorithm, so listing an algorithm the issuer publishes no key for accepts nothing. Pass a shorter list to pin the one your audience's tokens use. |
refuseActorTokens | false | Refuse tokens that carry act, meaning they were obtained by a service on someone's behalf. |
clockToleranceSeconds | 5 | Allowed clock difference on the token's time claims, a whole number from 0 to 300. A value outside that range throws a RangeError when the verifier is created, and is not clamped. |
requireAuthentication | none | { acrValues?, maxAgeS? } (RFC 9470). Refuse a verified token whose acr is not one of acrValues (or, for aal1 to aal3, does not reach the lowest listed) or whose auth_time is older than maxAgeS seconds (a token with no auth_time fails). The refusal is a 401 with error="insufficient_user_authentication" and the required acr_values and max_age in the challenge. |
clock | system clock | A function returning the current Date, for tests. |
Verify a request#
verifyRequest(request) takes a standard Request. verifyFacts({ authorization, dpop, method, url }) takes the pieces, for a framework whose request is not a standard one. Both resolve to a VerifiedAccessToken or throw ResourceServerError.
createResourceServer refuses a request unless all of these hold.
- The signature verifies against the issuer's published keys, and the algorithm is on your list.
typisat+jwtandissis the issuer exactly.- There is exactly one
aud, and it is youraudience. expandiatare present,expminusiatis at most 900 seconds, andjtiandsubare present.- With
requireConfirmation, the token hascnf. - A token bound with
cnf.jktarrives under theDPoPscheme with a valid proof: method, address,iat,ath, key and ajtinot seen before. ABearerlabel on a bound token is refused, and so isDPoPon an unbound one. - A token bound only by a certificate thumbprint is refused: this verifier does not check certificate binding.
The result#
VerifiedAccessToken:
| Field | Meaning |
|---|---|
subject | The token's sub, verbatim. |
principalId | sub parsed as an identifier, or null. |
tenantId | The organization from tid, or null. |
sessionId | sid, or null. |
scope | The scopes as an array. |
acr, amr, kind, av | From the token. |
actor | The act object, or null. |
confirmation | The cnf, or null. |
jti, issuedAt, expiresAt | Identifier and times. |
claims | The whole payload. |
It does not check scopes. Read token.scope where a route needs one.
Refusals#
A ResourceServerError has reason, tokenReason, proofReason, wwwAuthenticate and response(). The response is 401 with the challenge and a body of {"error":"invalid_token"}, {"error":"invalid_dpop_proof"} or {"error":"insufficient_user_authentication"}, and it never names the claim.
reason | Meaning |
|---|---|
missing_credentials | No Bearer or DPoP credentials. |
invalid_token | The token failed. tokenReason says which check: algorithm, signature, issuer, audience, audience_not_unique, typ, expired, not_yet_valid, lifetime, subject, tenant, confirmation, actor or malformed. |
dpop_scheme_required | A bound token arrived as Bearer. |
dpop_scheme_on_unbound_token | DPoP was used with a token that has no cnf. |
unsupported_confirmation | The binding is a mechanism the verifier does not check. |
invalid_dpop_proof | The proof failed. proofReason: missing, too_large, malformed, typ, alg, jwk, signature, htm, htu, iat, jti, ath, jkt or replay. |
insufficient_user_authentication | The token verified but its authentication is too old or too weak for requireAuthentication. Send the caller response(); a client answers it with rp.stepUpUrl (Relying-party module). |
Log tokenReason and proofReason on your side; do not send them to the caller.
Other exports#
verifyAccessToken(token, options)verifies a token alone, with no proof.verifyDpopProof(input)verifies a proof alone.createDpopProofOfPossession({ publicOrigin, replay })builds the hook the framework adapters need for bound tokens.remoteJwks(issuer)is the default key resolver.createMemoryDpopReplayStore({ maxEntries? })keeps 50,000 proofs for two minutes, per instance. Supply your ownDpopReplayStore, an object with aclaim(jti, expiresAt, now)method, over shared storage when you run several instances.
Worked example#
export async function GET(request: Request): Promise<Response> { try { const token = await resourceServer.verifyRequest(request) if (!token.scope.includes('reports.read')) return new Response(null, { status: 403 }) return Response.json({ caller: token.subject, organization: token.tenantId }) } catch (error) { if (error instanceof ResourceServerError) return error.response() throw error }}The scope name is yours.
Security notes#
- Put a rate limit in front. A full replay store refuses new proofs, so a caller with a valid token could fill it on purpose.
- Verification is local. It cannot see a revocation. For a sensitive action, ask introspection.
- Narrow
algorithmswhen you can. The default accepts both algorithms identity signs with. If your audience's tokens use one, list only that one. The algorithm always comes from your list, never from the token's own header. - Never use
verifyRequestfor an ID token. It will refuse it, which is correct.