Skip to content
TDACorpIDENTITYSign in

Protect an API in Node and Express

Verify identity access tokens and DPoP proofs in an Express API with a small middleware, check scopes, and test it.

On this page

This recipe puts token verification in front of the routes of an Express API. The same code works for any Node server, because it only needs the method, the address and two headers of the request.

Verification answers one question: was this token issued by identity, for this API, and does the caller hold the key it is bound to? It does not answer what the caller may do. Scope checks you can add in a line, and finer rules stay in your own code. The longer discussion is in Protect your API.

Decide your audience first#

A token reaches your API in one of two ways, and audience is different for each.

CallerThe token's audYou pass as audience
A person's application that asked for your API with resourceYour resource addressThe same address. See Resource indicators.
Another service that exchanged its tokenYour client idYour client id. Your service must be a registered client holding token_exchange. See Service to service.

A token addressed to anybody else is refused, including a plain client_credentials token from another service. That is correct.

The middleware#

import type { IncomingMessage, ServerResponse } from 'node:http'import { createMemoryDpopReplayStore, createResourceServer, ResourceServerError, type VerifiedAccessToken } from '@tdacorp/identity-sdk'const resourceServer = createResourceServer({  issuer: 'https://identity.tdacorp.in',  // The one audience your tokens carry: your resource address, or your client id for exchanged tokens.  audience: 'https://api.example.com',  // The origin callers use to reach you. Never read from the request.  publicOrigin: 'https://api.example.com',  // Per instance. Supply a shared store when you run several instances.  replay: createMemoryDpopReplayStore(),})// Express passes its own request and response, which extend these Node types.type Next = (error?: unknown) => voidexport type IdentityRequest = IncomingMessage & { originalUrl?: string; identity?: VerifiedAccessToken }const headerValue = (value: string | string[] | undefined): string | null => (typeof value === 'string' ? value : null)/** Verifies the access token and the DPoP proof, and puts the verified token on `req.identity`. */export async function requireIdentity(req: IdentityRequest, res: ServerResponse, next: Next): Promise<void> {  try {    req.identity = await resourceServer.verifyFacts({      authorization: headerValue(req.headers.authorization),      dpop: headerValue(req.headers.dpop),      method: req.method ?? 'GET',      url: req.originalUrl ?? req.url ?? '/',    })    next()  } catch (error) {    if (!(error instanceof ResourceServerError)) return next(error)    const refusal = error.response() // 401 with a WWW-Authenticate challenge and a fixed body    res.writeHead(refusal.status, Object.fromEntries(refusal.headers))    res.end(await refusal.text())  }}/** The verifier checks the token, not its scopes. A route that needs one checks it here. */export function requireScope(scope: string) {  return (req: IdentityRequest, res: ServerResponse, next: Next): void => {    if (req.identity?.scope.includes(scope) === true) return next()    res.writeHead(403, { 'content-type': 'application/json', 'cache-control': 'no-store' })    res.end(JSON.stringify({ error: 'insufficient_scope' }))  }}

Mount it like any Express middleware:

app.use('/api', requireIdentity)app.get('/api/documents', requireScope('documents'), (req, res) => { /* ... */ })

What the code does, and why:

  • verifyFacts takes the method, the address, and the Authorization and DPoP headers. It verifies the token's signature against the issuer's published keys, with an algorithm you allow, and checks iss, typ, aud and the lifetime. A token that is bound to a key must arrive under the DPoP scheme with a valid proof. verifyRequest does the same with a standard Request.
  • publicOrigin is the origin your callers use. The proof's htu is rebuilt from it plus the request path, never from the Host or X-Forwarded-* headers, which a caller or a proxy can set. Behind a load balancer, configure the public address and not the internal one.
  • req.originalUrl is used, not req.url, because Express changes url inside a mounted router and the proof is for the whole path.
  • Refusals come from ResourceServerError.response(): always 401, with a WWW-Authenticate challenge, and a body of {"error":"invalid_token"} or {"error":"invalid_dpop_proof"}. The body names no claim and never echoes the token. If a route needs a recent or strong sign-in, create the verifier with requireAuthentication: { acrValues?, maxAgeS? }: a token that falls short is refused with {"error":"insufficient_user_authentication"} and a challenge that tells the client what to step up to (RFC 9470). See Resource-server verification.

The verified token gives you subject (the token's sub, always the person's real identifier, even for a client registered pairwise), tenantId, sessionId, scope, kind, confirmation and, for an exchanged token, actor.

Check a scope#

The verifier checks the token and not its scopes. Read scope where a route needs one, as requireScope does. A missing scope is 403 with insufficient_scope, not 401: the caller is who they say they are and is not allowed.

Before you ship#

Replay protection#

A DPoP proof may be used once. The in-memory store remembers a proof for 120 seconds and holds up to 50,000. It is per process, so:

  • With several instances, or a platform where each request may reach a different one, a proof captured at one instance can be replayed once at another within its 60 second window. Implement the DpopReplayStore interface over a table or cache that all instances share if that matters to you.
  • A full store refuses new proofs rather than evict a live one, so a caller holding a valid token could fill it on purpose. Put a rate limit in front of your API.

Tokens that are not bound#

By default the verifier requires a token to carry a key binding. A person's token from a client that requires DPoP always does. Do not turn that requirement off unless you accept tokens from callers that cannot prove a key, and you understand that a stolen token then works for anyone.

Keys and time#

The verifier fetches identity's key set and caches it. See Environments and the issuer. It allows five seconds of clock difference for the token and 60 seconds for the proof. A server whose clock is further off than that will refuse good tokens. See Clock skew.

Test it#

With the key and the script from the Raw HTTP walkthrough, get a token and call your API the way a client would:

curl -i https://api.example.com/api/documents \  -H "Authorization: DPoP $ACCESS" \  -H "DPoP: $(node jwt.ts dpop GET https://api.example.com/api/documents '' "$ACCESS")"

Without credentials you should get 401. With a token for the wrong audience you should get 401. With a valid token you should get your data. Try the same token twice with the same proof: the second must be refused.

What is not covered yet#

The SDK also has a decision client, createIdentity, which asks identity whether a person may perform an action, and adapters for Next.js, Hono and Express built on it. That part is Available: the client authenticates to identity as your product with pdpCredential. See Protect your API.

Verified against commit 5d85077