Skip to content

Integrate

API: validate the token

How an API accepts IdP Urupema access tokens. Signature against the discovered JWKS, iss, exp, aud equal to the app's client_id, typ Bearer. The suite runs this exact code against the IdP.

An API trusts a request only after validating its access token, locally, against the IdP’s published keys. No call to the IdP per request, no introspection.

CheckRule
RS256 signature against the JWKS from discoverythe key set follows rotation
iss equals the issuera token from another issuer is another identity
exp in the futureaccess tokens live 300 s; no tolerance after expiry
aud contains the app’s client_ideach access token serves only the client that requested it (today aud is exactly that one value)
typ is Bearerrejects the ID token, which has the same aud and signature

The access token carries sub but not the name or email: for those, call the userinfo_endpoint with the same token. Authorization: the API looks up (iss, sub) in its own records. The token says who, never what.

Stack: Node.js 24, Express 5, jose v6. The file is site/examples/api-node/server.ts.

server.ts
import express from "express";
import type { NextFunction, Request, Response } from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";
const ISSUER = process.env.IDP_ISSUER!;
const CLIENT_ID = process.env.IDP_CLIENT_ID!; // the only `aud` this API accepts
// Discovery at boot; the JWKS comes from the issuer and follows key rotation.
const metadata = await fetch(`${ISSUER}/.well-known/openid-configuration`).then((r) => r.json());
if (metadata.issuer !== ISSUER) throw new Error(`unexpected issuer: ${metadata.issuer}`);
const JWKS = createRemoteJWKSet(new URL(metadata.jwks_uri));
server.ts
async function requireAccessToken(req: Request, res: Response, next: NextFunction) {
const [scheme, token] = (req.headers.authorization ?? "").split(" ");
if (scheme !== "Bearer" || !token) {
return res.status(401).set("WWW-Authenticate", "Bearer").json({ error: "missing_token" });
}
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: CLIENT_ID, // a token issued to another client is refused
algorithms: ["RS256"],
});
// The ID token has the same aud: refuse it by typ ("ID" vs "Bearer").
if (payload.typ !== "Bearer") throw new Error("not an access token");
res.locals.person = { iss: payload.iss!, sub: payload.sub! }; // the person is (iss, sub)
next();
} catch {
res.status(401).set("WWW-Authenticate", 'Bearer error="invalid_token"').json({ error: "invalid_token" });
}
}
server.ts
app.get("/api/me", requireAccessToken, (_req, res) => {
// Authenticated is not authorized: what this person may do is this API's decision,
// looked up by (iss, sub) in its own records.
res.json(res.locals.person);
});
.env
IDP_ISSUER=https://id.institutourupema.com.br/realms/urupema
# The aud this API accepts: the client_id of the app it belongs to.
IDP_CLIENT_ID=my-app-dev
PORT=4000

In the BFF shape the browser never holds the access token. The server calls the API with the token from its session, refreshed first when expired (the derived session does this).

const r = await fetch(`${API_URL}/api/me`, {
headers: { Authorization: `Bearer ${req.session.tokens!.access}` },
});
RequestAnswer
no Authorization header401, WWW-Authenticate: Bearer
an expired access token401, invalid_token
an access token issued to another client401, invalid_token
an ID token401, invalid_token

A token for a different API (one app calling another app’s API) is not available yet; see What the IdP is.