Protect an Express API
This quickstart protects an Express 5 API with KeenAuth access tokens. You add the expressAuth middleware of the Node.js SDK, require a scope on one route and test the API offline with the SDK test kit. Express is not a dependency of the SDK; the middleware also works with Express 4.
The API has three endpoints:
| Endpoint | Requires |
|---|---|
GET /api/public |
nothing |
GET /api/private |
a valid access token for your API |
GET /api/messages |
a valid access token with the messages:read scope |
Before you begin
- A KeenAuth environment. Its issuer looks like
https://your-environment.eu.keenauth.app. - An API registered in the environment (Create an API). Its
identifier, for examplehttps://api.example.com, is the audience of the tokens your API accepts. - The
messages:readscope on that API, granted to the application that calls it. - Node.js 22 or later.
1. Install the SDK
npm install @keenauth/node express@5.2.1
The SDK is not published yet. Until it is, the example links to the SDK source in its package.json.
2. Configure
The API reads its configuration from environment variables only. Put them in a .env file, which npm start loads with node --env-file-if-exists=.env, and never commit that file.
| Variable | Required | Meaning |
|---|---|---|
KEENAUTH_ISSUER |
yes | Your environment's issuer |
KEENAUTH_AUDIENCE |
yes | Your API's identifier |
PUBLIC_BASE_URL |
yes | The URL clients call (see Behind a proxy) |
KEENAUTH_WEBHOOK_SECRET |
recommended | The idp_whsec_ signing secret of the webhook endpoint that delivers token.revoked (two, comma separated, while you rotate it). Without it the API never learns of revoked tokens |
PORT |
no | Listen port, default 8080 |
3. Protect your routes
createApp creates the protector and the routes. expressAuth(protector) requires a valid token; with { scopes } it also requires scopes. The handler reads the verified claims from req.auth.
export function createApp({ verifier, publicBaseUrl, revocation, webhookSecrets = [] }) {
const protector = createProtector(verifier, {
publicBaseUrl,
revocation,
// The error never contains the token or claim values; never log the Authorization or DPoP header.
onError: (err) => console.warn(`rejected: ${err.message}`),
});
const app = express();
app.disable("x-powered-by");
// No token needed.
app.get("/api/public", (req, res) => {
res.json({ message: "Hello from a public endpoint." });
});
// Any valid access token for this API.
app.get("/api/private", expressAuth(protector), (req, res) => {
res.json({ message: "Hello from a private endpoint.", sub: req.auth.subject });
});
// A valid access token that also carries the messages:read scope.
app.get("/api/messages", expressAuth(protector, { scopes: ["messages:read"] }), (req, res) => {
res.json({ messages: ["Hello from a scoped endpoint."] });
});
// token.revoked deliveries from KeenAuth; the handler reads the raw body itself, so no JSON parser runs before it.
if (webhookSecrets.length > 0) {
app.post(WEBHOOK_PATH, ...createWebhookHandler({ secrets: webhookSecrets, revocation }));
} else {
console.warn("KEENAUTH_WEBHOOK_SECRET is not set: token.revoked events are not received, so revoked tokens stay valid until they expire");
}
return app;
}
server.js builds the real verifier from the configuration. The verifier fetches the signing keys from your environment the first time it sees a token.
const issuer = process.env.KEENAUTH_ISSUER;
const audience = process.env.KEENAUTH_AUDIENCE;
// The URL clients call: DPoP proofs are checked against it, never against the Host header.
const publicBaseUrl = process.env.PUBLIC_BASE_URL;
if (!issuer || !audience || !publicBaseUrl) {
console.error("KEENAUTH_ISSUER, KEENAUTH_AUDIENCE and PUBLIC_BASE_URL are required");
process.exit(1);
}
// createVerifier refuses an issuer under the reserved .invalid domain, so a test token can never pass here.
const verifier = createVerifier({ issuer, audience });
const revocation = createMemoryRevocationCache();
// The signing secret (idp_whsec_...) of the webhook endpoint that delivers token.revoked; comma separated while rotating.
const webhookSecrets = (process.env.KEENAUTH_WEBHOOK_SECRET ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const app = createApp({ verifier, publicBaseUrl, revocation, webhookSecrets });
const port = Number(process.env.PORT || 8080);
app.listen(port, () => console.log(`listening on port ${port}`));
4. Run and call the API
npm start
For a first call, get a token with the client credentials grant of a machine-to-machine application that has the messages:read scope on your API:
ACCESS_TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials \
-d resource=https://api.example.com \
-d scope=messages:read \
https://your-environment.eu.keenauth.app/oauth2/token | jq -r .access_token)
curl -i http://localhost:8080/api/messages -H "Authorization: Bearer $ACCESS_TOKEN"
5. Test without a network
@keenauth/node/testing mints tokens signed with a public test key, and createTestVerifier accepts only those tokens. The tests pass that verifier to createApp and call the app on a local port, so they need no network and no KeenAuth account. See Offline test kit.
test("scope check: 403 insufficient_scope without messages:read, 200 with it", async () => {
await expectProblem(await get("/api/messages", bearer(mintAccessToken(AUDIENCE))), 403, "insufficient_scope", /scope="messages:read"/);
const ok = await get("/api/messages", bearer(mintAccessToken(AUDIENCE, { claims: { scope: "profile messages:read" } })));
assert.equal(ok.status, 200);
});
npm test
The example's tests check that a valid token is accepted and that a missing, expired or wrong-audience token, a missing scope, a token in the URL query and a revoked token are rejected with the statuses below. They also send a DPoP-bound token with a proof for PUBLIC_BASE_URL, and check that server. The tests also send signed token.revokeddeliveries: a valid one revokes the token, a forged, tampered or stale one gets400 and revokes nothing, and a repeated one has one effect.js refuses to start with the test issuer.
Responses
| Request | Status | code in the body |
|---|---|---|
No Authorization header |
401 |
missing_token |
| Expired token, token for another API, bad signature, revoked token | 401 |
invalid_token |
| Token without the required scope | 403 |
insufficient_scope |
Token in the URL query (?access_token=) |
400 |
invalid_request |
The body is application/problem+json and the WWW-Authenticate header carries the standard challenge. The full table is in Protecting your API with middleware.
Behind a proxy
If clients use DPoP, the middleware checks that each proof was made for the URL the client called. Set PUBLIC_BASE_URL to the external URL clients call, for example https://api.example.com, also behind a proxy or load balancer. It is required and must use https (http only for localhost, 127.0.0.1 or [::1]); the app refuses to start without it. The middleware never trusts X-Forwarded-* headers, whatever Express's trust proxy setting is.
Revoked tokens
An access token stays valid until it expires, so KeenAuth tells your API when tokens are revoked. The example passes an in-memory revocation cache to the protector and serves a token.revoked webhook at POST /webhooks/keenauth (webhook.js) that feeds it.
- In the dashboard, create a webhook endpoint with the URL
https://<your host>/webhooks/keenauththat subscribes totoken.revoked, and export its signing secret asKEENAUTH_WEBHOOK_SECRET. - The handler verifies the signature with the SDK on the raw body and answers
400when it is invalid or the timestamp is more than five minutes off. It maps the event's selector (access_token,session,user,client,org,org_user,client_user) to a revocation marker and answers204; the middleware then rejects every matching token issued beforenot_before. - KeenAuth delivers at least once, so the handler applies each
webhook-idonce. If the marker cannot be stored it answers503and KeenAuth delivers the event again. It ignores other event types and selectors that no access token can match (refresh_family,api).
The handler logs the event id, the selector type and the reason, never the body, a signature or the secret. Each process has its own cache; with several instances, put the markers in a shared store behind the same revoke method. See Revocation cache.
Next steps
- Protecting your API with middleware: Fastify, step-up requirements, DPoP and every option.
- Verifying tokens: the verifier under the middleware.
- Offline test kit.