Skip to content
TDACorpIDENTITYSign in

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.

JobQuestionIn the SDKStatus
Verify the tokenWas this token issued by identity, for me, and does the caller hold its key?createResourceServerAvailable
DecideMay this person do this to this resource?createIdentity, pepRoute and pepAvailable

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). Its aud is that address. Pass the same address as audience.
  • A service token from an exchange (see Service to service). Its aud is your client id. For this to work, your service is a registered client that holds the token_exchange grant. Pass your client id as audience.

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 (EdDSA and ES256 by default, limited to what the issuer publishes a key for; anything else is refused when you create the server).
  • iss equals the issuer exactly and the typ header is at+jwt.
  • exp minus iat is at most 900 seconds.
  • There is exactly one aud, and it is your audience.
  • By default (requireConfirmation: true) the token carries cnf. A token bound with cnf.jkt must arrive under the DPoP scheme, with a proof whose htm, htu, iat (within 60 seconds), ath, key thumbprint and jti all check out. A Bearer label on a bound token is refused, and so is the DPoP scheme 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 DpopReplayStore over 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#

  1. Verify tokens with createResourceServer.
  2. Check token.scope where a route needs a scope.
  3. 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.

Verified against commit 5d85077