Skip to content

Protect an Express App

The Express connector provides caracalAuth, a request-level middleware that extracts the bearer mandate from the Authorization header, verifies the ES256 signature against the zone JWKS, checks revocation, enforces scope requirements, and optionally opens an ephemeral agent session for the request. Validated claims are attached to req.caracalClaims.

  • An Express app receiving requests from Caracal agents.
  • The STS JWKS endpoint reachable from the Express process.
Terminal window
npm install @caracalai/mcp-express @caracalai/revocation
import express from 'express';
import { caracalAuth } from '@caracalai/mcp-express';
import { InMemoryRevocationStore } from '@caracalai/revocation';
const app = express();
const revocations = new InMemoryRevocationStore();
app.use(
caracalAuth({
issuer: process.env.CARACAL_STS_URL!, // e.g. http://sts:8080
audience: 'resource://my-api',
zoneId: process.env.CARACAL_ZONE_ID!,
requiredScopes: ['api:read'],
revocations,
})
);

Every request that passes the middleware has req.caracalClaims populated:

import type { CaracalRequest } from '@caracalai/mcp-express';
app.get('/items', (req: CaracalRequest, res) => {
const claims = req.caracalClaims!;
// claims.sub — principal subject
// claims.zoneId — zone the mandate was issued in
// claims.scope — space-separated scopes
// claims.sid — session ID (for revocation lookup)
// claims.agentSessionId — set when called from a spawned agent
res.json({ principal: claims.sub });
});
OptionTypeRequiredDescription
issuerstringYesSTS base URL; JWKS fetched from {issuer}/.well-known/jwks.json
audiencestringYesMust match the aud claim in the mandate
zoneIdstringNoRejects mandates from a different zone
requiredScopesstring[]NoAll listed scopes must be in the mandate
requireAgentbooleanNoRejects mandates without agent_session_id
requireDelegationbooleanNoRejects mandates without delegation_edge_id
requireChainContainsstring[]NoAll listed application IDs must appear in delegation_chain
maxHopCountnumberNoRejects mandates with hop_count above this value
revocationsRevocationStoreYesChecked against the mandate’s sid claim
bindContextbooleanNoBind Caracal context from inbound headers (default: true)
ephemeralAgent{ coordinator, applicationId }NoOpen an ephemeral agent session per request and expose it on req.caracalContext

Apply caracalAuth with different scopes per route by mounting it as a route-level middleware instead of globally:

import { caracalAuth } from '@caracalai/mcp-express';
import { revocations } from './revocations';
const baseAuth = {
issuer: process.env.CARACAL_STS_URL!,
audience: 'resource://my-api',
revocations,
};
app.get(
'/items',
caracalAuth({ ...baseAuth, requiredScopes: ['api:read'] }),
(req: CaracalRequest, res) => res.json({ items: [] })
);
app.post(
'/items',
caracalAuth({ ...baseAuth, requiredScopes: ['api:write'] }),
(req: CaracalRequest, res) => res.status(201).json({ created: true })
);
app.delete(
'/items/:id',
caracalAuth({ ...baseAuth, requiredScopes: ['api:write', 'api:delete'] }),
(req: CaracalRequest, res) => res.status(204).send()
);

When ephemeralAgent is set, the middleware opens a fresh agent session for each request and terminates it when the response finishes. The active context is available on req.caracalContext:

app.use(
caracalAuth({
issuer: process.env.CARACAL_STS_URL!,
audience: 'resource://my-api',
revocations,
ephemeralAgent: {
coordinator: { baseUrl: process.env.CARACAL_COORDINATOR_URL! },
applicationId: process.env.CARACAL_APPLICATION_ID!,
},
})
);
app.post('/workflow', async (req: CaracalRequest, res) => {
const ctx = req.caracalContext!;
// ctx.agentSessionId — the per-request ephemeral session
// Use caracal.current() if you also loaded the SDK with Caracal.fromEnv()
res.json({ session: ctx.agentSessionId });
});

The middleware sends structured JSON errors. Override error handling by adding an Express error handler after the middleware:

ScenarioStatuserror
Missing Authorization header401missing_token
Invalid JWT signature or expired401invalid_token
Zone mismatch401invalid_zone
Missing required scope403insufficient_scope
Session revoked403session_revoked
Agent session required403agent_required
Delegation required403delegation_required
Required app not in chain403chain_mismatch
Hop count exceeded403hop_count_exceeded

Replace InMemoryRevocationStore with the Redis-backed store to share revocation state across replicas. See Protect an MCP Server for the Redis setup pattern.

import express from 'express';
import { caracalAuth, type CaracalRequest } from '@caracalai/mcp-express';
import { InMemoryRevocationStore } from '@caracalai/revocation';
const app = express();
app.use(express.json());
const revocations = new InMemoryRevocationStore();
const auth = (scopes: string[]) =>
caracalAuth({
issuer: process.env.CARACAL_STS_URL!,
audience: 'resource://inventory',
zoneId: process.env.CARACAL_ZONE_ID!,
revocations,
requiredScopes: scopes,
requireAgent: true,
});
app.get('/inventory', auth(['inventory:read']), (req: CaracalRequest, res) => {
res.json({ items: [], requestedBy: req.caracalClaims!.sub });
});
app.post('/inventory', auth(['inventory:write']), (req: CaracalRequest, res) => {
res.status(201).json({ created: req.body });
});
app.listen(8080, () => console.log('listening on :8080'));