Skip to content
TDACorpIDENTITYSign in

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(),})
OptionDefaultMeaning
issuerrequiredThe issuer you trust.
audiencerequiredThe one value your tokens carry in aud: your resource address, or your client id for exchanged tokens. Not empty, and not the issuer.
publicOriginrequiredThe 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.
replayrequiredA DPoP replay store. Construction fails without one.
requireConfirmationtrueRequire cnf on every token. Set false only if you accept unbound tokens.
jwksthe issuer's key setA 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.
refuseActorTokensfalseRefuse tokens that carry act, meaning they were obtained by a service on someone's behalf.
clockToleranceSeconds5Allowed 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.
requireAuthenticationnone{ 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.
clocksystem clockA 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.
  • typ is at+jwt and iss is the issuer exactly.
  • There is exactly one aud, and it is your audience.
  • exp and iat are present, exp minus iat is at most 900 seconds, and jti and sub are present.
  • With requireConfirmation, the token has cnf.
  • A token bound with cnf.jkt arrives under the DPoP scheme with a valid proof: method, address, iat, ath, key and a jti not seen before. A Bearer label on a bound token is refused, and so is DPoP on an unbound one.
  • A token bound only by a certificate thumbprint is refused: this verifier does not check certificate binding.

The result#

VerifiedAccessToken:

FieldMeaning
subjectThe token's sub, verbatim.
principalIdsub parsed as an identifier, or null.
tenantIdThe organization from tid, or null.
sessionIdsid, or null.
scopeThe scopes as an array.
acr, amr, kind, avFrom the token.
actorThe act object, or null.
confirmationThe cnf, or null.
jti, issuedAt, expiresAtIdentifier and times.
claimsThe 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.

reasonMeaning
missing_credentialsNo Bearer or DPoP credentials.
invalid_tokenThe 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_requiredA bound token arrived as Bearer.
dpop_scheme_on_unbound_tokenDPoP was used with a token that has no cnf.
unsupported_confirmationThe binding is a mechanism the verifier does not check.
invalid_dpop_proofThe proof failed. proofReason: missing, too_large, malformed, typ, alg, jwk, signature, htm, htu, iat, jti, ath, jkt or replay.
insufficient_user_authenticationThe 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 own DpopReplayStore, an object with a claim(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 algorithms when 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 verifyRequest for an ID token. It will refuse it, which is correct.

Verified against commit 5d85077