Advanced
Securing capabilities
Authenticate the HTTP endpoints that expose your capabilities, and enrich the caller's identity.
When you need this
Stdio transport runs as a local subprocess with no network surface, so it needs no authentication. The moment you switch a capability to the HTTP transport (a long-running server multiple clients reach over the network), you must secure it. This page covers every authentication mode Routecraft ships, from a static signing key to OAuth 2.0 resource-server verification, plus identity enrichment, discovery metadata, and CORS.
For wiring the server itself and pointing clients at it, see Running an MCP server. For a concrete, copyable capability, see the MCP example.
The credential ladder
Every http surface (application routes, the ops management API, MCP) verifies against the same auth options, so securing an instance is one decision made at the right rung. Routecraft verifies; issuers issue. Nothing here mints, stores, or refreshes a credential, which is why every rung survives contact with a real IdP later.
Each rung is discoverable by a refused caller: a 401 (and the ops 403) carries an RFC 9728 resource_metadata hint, the instance serves the document it points at, and craft exec follows it to report which scope is required, who issues, and how to supply a credential. See Protected-resource metadata.
Rung 1: no auth
Nothing configured, and a route with no .authorize(). Right for local development and for a listener that is only reachable inside a trusted network boundary.
// craft.config.ts
import { defineConfig } from '@routecraft/routecraft'
export const craftConfig = defineConfig({
servers: { default: { port: 8080 } },
ops: { tiers: { dispatch: true } },
})
craft exec <route> reaches it with no flags at all.
Rung 2: a static key
One shared secret in an environment variable, compared in constant time. The everyday local answer, and enough for a single-operator deployment. The comparison MUST be timingSafeStringEqual: === returns as soon as two bytes differ, and that timing is measurable.
// craft.config.ts
import {
defineConfig,
timingSafeStringEqual,
type Principal,
} from '@routecraft/routecraft'
export const craftConfig = defineConfig({
servers: {
default: {
port: 8080,
auth: {
validator: (token: string): Principal => {
if (!timingSafeStringEqual(process.env.CRAFT_API_KEY!, token)) {
throw new Error('unknown token')
}
return {
kind: 'custom',
scheme: 'bearer',
subject: 'operator',
scopes: ['ops:dispatch'],
}
},
},
},
},
ops: { tiers: { dispatch: 'ops:dispatch' } },
})
Present the same key from the caller's side: craft exec <route> --token "$CRAFT_API_KEY", or put it as token in .routecraft/settings.yaml (gitignored), or export CRAFT_TOKEN.
Rung 3: a self-signed JWT
jwt({ secret }) verifies tokens you mint with your own tooling, which buys you expiry and per-token scopes without an IdP. issuer and audience are required on the verifying side, so mint with the same values.
// craft.config.ts
import { defineConfig, jwt } from '@routecraft/routecraft'
export const craftConfig = defineConfig({
servers: {
default: {
port: 8080,
auth: jwt({
secret: process.env.JWT_SECRET!,
issuer: 'https://ops.example.com',
audience: 'https://ops.example.com',
}),
},
},
ops: { tiers: { dispatch: 'ops:dispatch' } },
})
Mint with anything that signs HS256, for example jose:
// mint-token.ts (operator tooling, not part of the app)
import { SignJWT } from 'jose'
const token = await new SignJWT({ scope: 'ops:dispatch' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuer('https://ops.example.com')
.setAudience('https://ops.example.com')
.setSubject('operator')
.setExpirationTime('8h')
.sign(new TextEncoder().encode(process.env.JWT_SECRET!))
console.log(token)
Rung 4: a real IdP
jwks() at the IdP's keys. The metadata document now names the issuer, so discovery tells a refused caller exactly where to go; token acquisition is the caller's business with the IdP.
// craft.config.ts
import { defineConfig, jwks } from '@routecraft/routecraft'
export const craftConfig = defineConfig({
servers: {
default: {
port: 8080,
auth: jwks({
jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
issuer: 'https://idp.example.com',
audience: 'https://ops.example.com',
}),
},
},
ops: { tiers: { dispatch: 'ops:dispatch' } },
})
You attach authentication with the auth option on mcpPlugin({ transport: 'http' }):
// craft.config.ts
import { defineConfig } from '@routecraft/routecraft'
import { mcpPlugin, jwt } from '@routecraft/ai'
export const craftConfig = defineConfig({
servers: {
default: { port: 3001 },
},
plugins: [
mcpPlugin({
transport: 'http',
// Required outside NODE_ENV development/test; see Protected-resource
// metadata below.
resource: { url: 'https://mcp.example.com/mcp' },
auth: jwt({
secret: process.env.JWT_SECRET!,
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
}),
}),
],
})
Static-key JWT (jwt())
Routecraft ships with a built-in jwt() helper that verifies JWT signatures using node:crypto (zero dependencies). issuer and audience are required to prevent cross-issuer and cross-audience replay. Both accept a single string or an array of accepted values. Use audience: "*" only when you explicitly want to skip audience validation.
import { jwt } from '@routecraft/ai'
// HMAC (HS256, default)
auth: jwt({
secret: process.env.JWT_SECRET!,
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
})
// RSA (RS256)
auth: jwt({
algorithm: 'RS256',
publicKey: fs.readFileSync('./public.pem', 'utf-8'),
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
})
jwt and jwks are also exported from @routecraft/routecraft -- the @routecraft/ai re-export is a convenience.
JWKS-backed JWT (jwks())
For JWTs signed by an external IdP, use jwks(). It lazy-loads jose and fetches the public key set from the IdP's JWKS endpoint:
import { jwks } from '@routecraft/ai'
auth: jwks({
jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
})
For non-standard IdPs that use different claim names, override individual mappings with claims:
auth: jwks({
jwksUrl: 'https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys',
issuer: 'https://login.microsoftonline.com/<tenant>/v2.0',
audience: '<app-id>',
claims: {
subject: (p) => p.oid as string,
roles: (p) => p['roles'] as string[] | undefined,
},
})
Custom validator
For API keys, opaque tokens, or any other scheme, pass a validator function. Throw to reject; return a Principal to accept. A rejected credential answers 401, but a throw that names an infrastructure failure (an unreachable JWKS endpoint, a failed userinfo fetch) answers 500 on every auth mode, so a client retries rather than discarding a credential that is probably valid:
auth: {
validator: async (token) => {
const user = await db.verifyApiKey(token)
if (!user) throw new Error('unknown key')
return {
kind: 'custom',
scheme: 'bearer',
subject: user.id,
name: user.label,
}
},
}
The returned Principal is a flat object tagged with kind ("jwt", "jwks", "oauth", or "custom"). It rides on the exchange as a structured routecraft.auth.principal header and is exposed ergonomically via the ex.principal getter.
OAuth resource server (oauth())
A Routecraft MCP server is an OAuth 2.0 Resource Server. It verifies bearer tokens, enforces required scopes, and advertises its Authorization Server through RFC 9728 metadata; clients run the authorization flow directly against your IdP. Routecraft does not proxy /authorize, /token, /register or /revoke, so there is no web framework to install: jose (for JWKS verification) is the only optional peer.
bun add jose
Compose oauth() with jwks() (or a raw verifier function) via the verify option. The protected-resource identity (resource.url) lives on the plugin, not on oauth():
import { mcpPlugin, oauth, jwks } from '@routecraft/ai'
mcpPlugin({
transport: 'http',
resource: { url: 'https://mcp.example.com' },
auth: oauth({
verify: jwks({
jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
}),
// Refused with 403 insufficient_scope when the token lacks any of these.
requiredScopes: ['mcp:invoke'],
}),
})
oauth() is a thin layer over the same options jwks() and jwt() produce: reach for it when you want requiredScopes enforcement or an explicit issuer, and pass the validator straight to auth otherwise. The issuer comes from the verify helper automatically; pass issuer explicitly when verify is a raw function, since nothing else names the IdP for clients to discover.
For opaque tokens or custom introspection, pass a raw verify function instead:
auth: oauth({
issuer: 'https://idp.example.com',
verify: async (token) => {
const info = await myIntrospectionCall(token)
if (!info.active) throw new Error('token inactive')
if (typeof info.exp !== 'number') throw new Error('token has no exp')
return {
kind: 'oauth',
scheme: 'bearer',
subject: info.sub,
clientId: info.client_id,
expiresAt: info.exp,
}
},
})
verify runs on every request: revision 2026-07-28 is stateless, so there is no session in which a past verification could be cached. Keep introspection calls fast, or cache them yourself.
expiresAt is required on a principal returned through oauth(): a principal without a finite numeric expiry has no bounded validity window, so it is refused rather than admitted indefinitely. A principal whose expiry has already passed is refused at the gate whichever auth mode produced it. The boundary is inclusive and compared in whole seconds, so a principal whose expiresAt equals the current second is already expired, matching RFC 7519 section 4.1.4.
The populated Principal rides on the exchange as a single structured header (routecraft.auth.principal) and is exposed ergonomically via the ex.principal getter, e.g. ex.principal?.subject, ex.principal?.scopes, ex.principal?.claims.
Principal enrichment via userinfo
OAuth access tokens are intentionally thin: they authorize but rarely identify. Identity fields needed to gate routes (email, name, roles, org membership) usually live behind the IdP's userinfo endpoint, not in the token itself. The optional userinfo option on mcpPlugin({}) runs after auth verifies the token and merges enrichment onto the verified principal.
userinfo is plugin-level and orthogonal to the auth mode: it works with jwks() / jwt(), a custom { validator }, and oauth(). This is the path for IdPs like WorkOS AuthKit where the token itself is thin but you still need richer identity.
Three shapes are accepted; choose exactly one.
Shape 1: auto-discover via OIDC Discovery. Requires a single-string issuer on the verify helper (jwks({ issuer }) / jwt({ issuer })). The framework resolves the userinfo endpoint from the discovery document at ${issuer}/.well-known/openid-configuration and caches the URL honouring Cache-Control: max-age (default 1 hour).
mcpPlugin({
transport: 'http',
auth: jwks({ jwksUrl, issuer: 'https://idp.example.com', audience }),
userinfo: true,
})
Shape 2: explicit userinfo endpoint URL. Skips discovery; use when the IdP does not advertise OIDC Discovery or you want to pin the URL explicitly.
mcpPlugin({
transport: 'http',
auth: jwks({ jwksUrl, issuer: 'https://idp.example.com', audience }),
userinfo: 'https://idp.example.com/oauth/userinfo',
})
Shape 3: custom function for non-OIDC backends (WorkOS / Clerk Backend API, internal DB, etc.). Sub-invariant enforcement is the caller's responsibility in this mode.
mcpPlugin({
transport: 'http',
auth: jwks({ jwksUrl, issuer: 'https://idp.example.com', audience }),
userinfo: async (principal, token) => {
const [profile, roles] = await Promise.all([
fetch('https://idp.example.com/oauth/userinfo', {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json()),
myService.getRoles(principal.subject),
])
return { ...profile, roles }
},
})
The same userinfo option works unchanged when auth is oauth({}).
Semantics:
- Default is no enrichment. When
userinfois omitted, the principal carries only what the token itself provided (email/name/rolesonly if those claims are in the JWT). Setuserinfoto fetch them for thin tokens. - Runs after verify. The verified principal is the starting point; userinfo only adds or overwrites non-protected fields.
- Verify wins on protected fields.
subject,issuer,audience,expiresAt, andclaimsalways come from the token. An enrichment that tries to overwrite them is silently dropped. The raw userinfo response is surfaced on a separateuserinfoClaimsfield soprincipal.claimskeeps its meaning ("verified JWT payload") regardless of whether enrichment ran. subinvariant (URL and discovery modes). The userinfo response MUST includesuband it MUST equal the verified token'ssub(OIDC Core §5.3.2). Mismatches reject the request withRC5022. The function variant is trusted by contract.- Auto-discovery (
userinfo: true). The framework fetches the OIDC Discovery document relative to the verifier'sissuer(preserving the issuer's path, so Keycloak realms and tenant-prefixed IdPs work), readsuserinfo_endpoint, and caches the resolved URL honouring the response'sCache-Control: max-age(default one hour). A missing single-string issuer fails fast at startup; a missinguserinfo_endpointor unreachable discovery doc raisesRC5021on the first request. - Token-bound enrichment caching with coalescing. The verifier runs on every request, so dynamic checks (introspection, revocation, clock comparisons) still fire per request. Only the enrichment payload is memoised, keyed by SHA-256 of the bearer (not the raw bearer) and evicted at
expiresAt. The cache has a default cap of 10,000 entries with insertion-order eviction. Concurrent first-callers for the same token share a single in-flight enrichment, so the IdP receives one userinfo fetch per token, not one per inbound request. - Fail-closed. Userinfo fetch, parse, and discovery errors raise
RC5021; sub-invariant violations raiseRC5022. There is no opt-in "best effort" mode; if you need that, write a function variant that swallows its own errors.
If authorize() runs mid-pipeline after a slow step, set authorize({ clockToleranceSec }) to the same value used on the source-side verifier so a token accepted at the route boundary is not rejected by a fraction of a second.
Use userinfo when the bearer alone does not carry the identity fields you need. Skip it when the token already contains everything (e.g. a JWT with email and roles claims).
See the mcpPlugin reference for the full Principal field list.
Protected-resource metadata (RFC 9728)
Auto-discovering MCP clients (Claude.ai custom connectors, MCP Inspector, mcp-remote, Claude Desktop) probe the MCP path, receive a 401, then fetch the path-suffixed RFC 9728 document (/.well-known/oauth-protected-resource/mcp for the default path) to find out which authorization server to use. The framework serves this metadata document whichever auth helper you use, appends a resource_metadata="..." parameter to the 401 WWW-Authenticate header so clients know where the document lives, and serves it regardless of credential state, so a client holding a stale token can still discover where to re-authenticate.
Discovery is not MCP-only: every http server serves /.well-known/oauth-protected-resource (and a path-suffixed document mirroring any path, per RFC 9728 section 3.1), sourced from the effective validator of the mount that owns the mirrored path: issuer from jwt() / jwks() when present, scopes_supported from what the surface declares (the ops mount declares its scope-gated tiers). A bare { validator } with no issuer produces an honest minimal document: bearer accepted, no discoverable authorization server. Every bearer 401 on any mount carries the resource_metadata hint, and the ops 403 carries it alongside the scope it already named, which is what lets craft exec report scope, issuer, and remedy on a refusal.
Protected-resource identity is configured on the plugin, not on the auth helper. It is orthogonal to the auth mode: the same resource: {...} block works whether you use jwt() / jwks() directly or via oauth().
// with servers: { public: { host: '0.0.0.0', port: 3001 } } in defineConfig
mcpPlugin({
name: 'eywa', // machine identifier (MCP `serverInfo.name`)
title: 'Eywa MCP', // human display; also the metadata `resource_name`
transport: 'http',
server: 'public',
resource: {
url: 'https://mcp.example.com', // metadata `resource` field; defaults to bound URL
scopesSupported: ['read', 'write'], // metadata `scopes_supported`
documentationUrl: 'https://docs.example.com', // metadata `resource_documentation`
},
auth: jwks({
jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
}),
})
The metadata document populates authorization_servers from the auth options' issuer, surfaced by jwks() / jwt() / oauth(). A custom validator with no declared issuer omits the field, which RFC 9728 allows, though clients then have no way to discover where to authenticate.
Outside a named development environment (NODE_ENV set to exactly development or test), an explicit HTTPS resource.url is required for the HTTP transport: omitting it, or supplying an http:// URL, throws at construction time. This deliberately includes an unset or misspelled NODE_ENV, the commonest production misconfiguration, because a shared listener is typically published through a TLS-terminating proxy whose public origin cannot be inferred safely from the local bind address. In development / test the framework falls back to advertising the bound http://{host}:{port}{path} as a convenience.
This is how MCP clients auto-discover your IdP. Protocol revision 2026-07-28 deprecated Dynamic Client Registration in favour of Client ID Metadata Documents, so the discovery document plus a direct flow against the IdP is the supported shape for every provider, including ones like WorkOS AuthKit that never offered server-side DCR.
CORS
Browser-based MCP clients (MCP Inspector UI, Claude.ai custom connectors, web-hosted Claude Desktop) need CORS headers on the MCP HTTP transport. The framework handles this on three surfaces: the MCP path, the path-suffixed metadata document (/.well-known/oauth-protected-resource/mcp for the default path), and the 401 WWW-Authenticate response.
MCP and ACP first validate Host in shared ingress, independently of CORS and auth. The bound hostname is trusted. Loopback and wildcard binds also accept localhost, 127.0.0.1 and [::1]. For a reverse proxy's public hostname, configure servers.<name>.allowedHostnames. MCP additionally trusts its own resource.url hostname. Missing, malformed or foreign Host values return 403; Forwarded and X-Forwarded-Host never establish trust. Wildcard binds do not grant wildcard hostname trust.
Requests carrying Origin are denied by default, including local browser tools. Opt a browser in with the mount's browserOrigins: an array of exact HTTP(S) origins, with scheme and port, without trailing slash, path or wildcard. This is independent of cors. A permissive CORS setting never grants browser admission, and cors: false disables only CORS handling. Requests without Origin, such as ordinary editor and server clients, remain unaffected by browser admission. These checks do not authenticate clients: a non-browser client can choose its own headers, so remote access still needs auth.
The default CORS response-header policy remains loopback-only. Once explicitly admitted, local tooling can read responses without another CORS setting:
mcpPlugin({
transport: 'http',
auth: jwks({ jwksUrl: '...', issuer: '...', audience: '...' }),
browserOrigins: ['http://localhost:6274'],
})
For a remote browser, configure admission and response access separately:
mcpPlugin({
transport: 'http',
resource: { url: 'https://agents.example.com/mcp' },
auth: jwks({ jwksUrl: '...', issuer: '...', audience: '...' }),
browserOrigins: ['https://editor.example.com'],
cors: { origin: 'https://editor.example.com' },
})
ACP uses the same settings. This configuration supports a proxy that preserves Host: agents.example.com and supplies CORS headers itself:
import { defineConfig, jwt } from '@routecraft/routecraft'
import '@routecraft/ai'
export const proxiedAgentConfig = defineConfig({
servers: {
default: {
host: '127.0.0.1',
port: 8080,
allowedHostnames: ['agents.example.com'],
auth: jwt({ secret: process.env.JWT_SECRET!, issuer: 'https://idp.example.com', audience: 'https://agents.example.com' }),
},
},
agent: {
agents: {
assistant: { description: 'Assistant', model: 'anthropic:claude-sonnet-4-6', system: 'Be useful.' },
},
},
acp: {
browserOrigins: ['https://editor.example.com'],
cors: false,
},
})
cors.origin retains its exact string, string-array, wildcard and resolver forms. When CORS handling is enabled, an Origin refused by that policy is still rejected, even if listed in browserOrigins. The framework controls allowed methods (GET, POST, OPTIONS), allowed headers (Authorization, *: the Fetch spec leaves Authorization out of the wildcard, so a bearer token is named), and exposed headers (WWW-Authenticate). The Host and browser-origin gate also covers preflight and the protocol's path-suffixed discovery document.
Migration for 0.7.0: browser clients previously admitted by CORS must add browserOrigins, including loopback tools. Proxy-facing ACP mounts must configure their public Host in allowedHostnames when the proxy preserves it. Native editor clients using a bound hostname need no changes. Gate rejections return 403 without CORS headers and emit server:request:rejected with the server, mount and host or origin reason.
Agents acting on behalf of users
When an agent (or any delegate) exercises a user's authority, the principal records both parties: subject stays the user the action is for, actor names the agent driving it (RFC 8693 act semantics). The delegate operation establishes this after identity is verified, intersecting scopes under a consent-derived ceiling so delegation can only narrow authority, never widen it. Roles pass through unchanged: they describe who the subject is, while scopes describe what the credential may do.
Every route then declares who may drive it via authorize():
// Humans only (this is the default: actor 'none')
.authorize({ roles: ['finance'], actor: 'none' })
// A member directly, or one named agent on a member's behalf
.authorize({
roles: ['member'],
scopes: ['mail:send'],
actor: ['none', { subject: 'agent:zoe', issuer: 'https://agents.example.com' }],
})
// Autonomous agents only (cron-triggered background work)
.authorize({ subject: { profile: 'ai_agent' }, actor: 'none' })
Three rules keep the model sound:
- Identification is not authorization. A verified channel identifier (a DKIM-passing sender, a Slack user id) says who someone is, never what an agent may do for them. Convert identity into delegated authority only through an explicit consent record, and mint it with
.delegate(), not by handing the agent the user's full principal. - Only the outermost actor is policy input. Nested actors in a chain are audit data (RFC 8693 section 4.1);
authorize({ actor })matches the current actor andmaxDelegationDepthbounds the chain.authorize({ effective: true }), which lets an agent satisfy a scope check from its own standing ring, reads the same one actor and no deeper. - Delegation claims fail closed at the token boundary. A verified token whose
actormay_actclaim the parser cannot read is rejected, never silently stripped: dropping anactwould promote a delegated token to a direct call and pass anactor: 'none'route, and dropping amay_actwould turn a restriction into permission. Map non-standard shapes (an actor identified byclient_id, for instance) withClaimMappers.actor/ClaimMappers.mayAct. - Autonomous authority is minted from internal triggers only. An agent acting as its own subject (
subjectProfile: 'ai_agent', no actor) should be minted on cron or timer sources, never from an externally reachable channel, so inbound messages can never trigger an agent's standing authority.
Security checklist
- Validate all inputs -- every capability should have a schema; Routecraft enforces it before execution
- Authenticate HTTP endpoints -- always set
authwhen using HTTP transport in production - Guardrails -- use
.filter()to reject exchanges that fail a business rule, and.transform()to sanitize or normalise values before they reach downstream systems - Authorize per route -- gate sensitive capabilities with
authorize()against the verified principal's roles or scopes - Principle of least privilege -- only expose capabilities the AI actually needs
- Govern agent tool access -- hand an agent a wrapped
Direct(...)route instead of a rawMCP(...)tool when it needs authorization, caching, or timeouts; see Calling an MCP - Audit trail -- add
.tap(log())to record every invocation; subscribe toplugin:mcp:tool:**events for MCP-specific tracing - Never hardcode credentials -- use
process.envand.envfiles
Related
Running an MCP server
Transports, client wiring, and server identity.
MCP tool
A copyable capability exposed as an MCP tool.
mcpPlugin reference
Full plugin options and the Principal field list.