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.
| Check | Rule |
|---|---|
| RS256 signature against the JWKS from discovery | the key set follows rotation |
iss equals the issuer | a token from another issuer is another identity |
exp in the future | access tokens live 300 s; no tolerance after expiry |
aud contains the app’s client_id | each access token serves only the client that requested it (today aud is exactly that one value) |
typ is Bearer | rejects 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.
1. Keys from discovery
Section titled “1. Keys from discovery”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));2. The middleware
Section titled “2. The middleware”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" }); }}3. A protected route
Section titled “3. A protected route”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);});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-devPORT=4000Calling the API from your server
Section titled “Calling the API from your server”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}` },});Refused requests
Section titled “Refused requests”| Request | Answer |
|---|---|
no Authorization header | 401, WWW-Authenticate: Bearer |
| an expired access token | 401, invalid_token |
| an access token issued to another client | 401, invalid_token |
| an ID token | 401, invalid_token |
A token for a different API (one app calling another app’s API) is not available yet; see What the IdP is.