Protect Your MCP Server (or Any Resource Server)
Your MCP server — or any API — can accept Rakomi-issued access tokens without an API key and without
hand-rolling JWT verification. Three helpers from @rakomi/node cover the whole resource-server loop:
- Serve discovery metadata at your well-known URL with
buildProtectedResourceMetadata()(RFC 9728 §2). - Challenge unauthenticated requests with
buildChallenge()(RFC 9728 §5.1 / RFC 6750). - Verify inbound tokens with
verifyRakomiToken()and branch on its typed error codes.
All three use only Web-standard APIs (fetch + WebCrypto), so they run on Node.js and edge runtimes alike.
This guide targets the MCP authorization specification revision 2025-11-25. The verification rules are written verifier-agnostically (“your verifier MUST…”) so they double as a spec if you implement them in another language.
Quick start
Section titled “Quick start”-
Install the SDK:
npm install @rakomi/node. -
Pick ONE canonical resource identifier and thread it everywhere (see the equality chain below).
-
Wire the three helpers into your middleware:
import { buildChallenge, buildProtectedResourceMetadata, verifyRakomiToken } from '@rakomi/node';const RESOURCE_ID = 'https://api.example.com/mcp';const PRM_URL = 'https://api.example.com/.well-known/oauth-protected-resource/mcp';const PRM = buildProtectedResourceMetadata({ resource: RESOURCE_ID });app.get('/.well-known/oauth-protected-resource/mcp', (_req, res) =>res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json(PRM));app.use('/mcp', async (req, res, next) => {const token = req.headers.authorization?.startsWith('Bearer ')? req.headers.authorization.slice(7) : undefined;if (!token) { // no token → challenge WITHOUT an error code (RFC 6750 §3.1)return res.status(401).set('WWW-Authenticate', buildChallenge({ resourceMetadataUrl: PRM_URL })).end();}const result = await verifyRakomiToken(token, { audience: RESOURCE_ID });if (!result.ok) {if (result.error.code.startsWith('config/')) { console.error(result.error.code); return res.status(500).end(); }return res.status(401).set('WWW-Authenticate', buildChallenge({ resourceMetadataUrl: PRM_URL, error: 'invalid_token' })).end();}// Scope enforcement is YOUR authorization decision — the helper does not check scopes.if (!new Set(result.data.scopes ?? []).has('notes:read')) {return res.status(403).set('WWW-Authenticate', buildChallenge({ resourceMetadataUrl: PRM_URL, error: 'insufficient_scope', scope: 'notes:read' })).end();}res.locals.auth = result.data;next();});
Serve discovery metadata
Section titled “Serve discovery metadata”buildProtectedResourceMetadata() returns the RFC 9728 §2 document describing YOUR resource server
(it is unrelated to the metadata Rakomi serves for its own endpoints):
const PRM = buildProtectedResourceMetadata({ resource: 'https://api.example.com/mcp', // https-only, no fragment — throws early otherwise scopesSupported: ['notes:read'], // optional resourceName: 'Example Notes API', // optional, shown on consent UIs});// { resource, authorization_servers: ['https://rakomi.com'], bearer_methods_supported: ['header'], ... }It throws a typed error at build time on invalid input — a deliberate contrast with the verify surface, which never throws: serving a malformed discovery document is worse than failing your boot.
The well-known path rule
Section titled “The well-known path rule”MCP clients construct the metadata URL from your resource identifier by inserting the path AFTER the well-known segment (RFC 9728 §3):
| Resource identifier | Metadata must be served at |
|---|---|
https://api.example.com | https://api.example.com/.well-known/oauth-protected-resource |
https://api.example.com/mcp | https://api.example.com/.well-known/oauth-protected-resource/mcp |
If your resource identifier has a path component and you serve the document only at the bare
/.well-known/oauth-protected-resource, clients that follow the spec will not find it.
Serving guidance
Section titled “Serving guidance”- Constants only, never request-derived values. Build the document once at boot from configuration.
Deriving any URL in it from the incoming
Hostheader would let a spoofed header advertise an attacker’s authorization server to your clients — a token-phishing pivot. The same rule applies to thejwksUrlandissuerverification options: the key-source address is the most dangerous value of all to derive from a request. - Public GET, no authentication (discovery is unauthenticated by design).
- CORS: send
Access-Control-Allow-Origin: *. Browser-based MCP clients fetch the document cross-origin and fail without it. - Cache it. The document is constant — a
Cache-Control: public, max-age=3600is a sensible default.
Emit the challenge
Section titled “Emit the challenge”buildChallenge() returns the WWW-Authenticate header value for your 401/403 responses, pointing
clients at your metadata URL:
buildChallenge({ resourceMetadataUrl: PRM_URL });// 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"'
buildChallenge({ resourceMetadataUrl: PRM_URL, error: 'insufficient_scope', scope: 'notes:read' });// 'Bearer error="insufficient_scope", resource_metadata="…", scope="notes:read"'erroris a closed union:'invalid_token' | 'insufficient_scope'. The third RFC 6750 code,invalid_request, is deliberately excluded — a 400 response does not carry this challenge.- When no token was presented, omit
errorentirely (RFC 6750 §3.1: error codes only accompany a presented token). - Parameter order (
error,resource_metadata,scope) is an output-stability guarantee of the function, not a protocol requirement — never parse challenges positionally (RFC 9110 §11.2). - Inputs are validated fail-closed: control characters and illegal scope characters throw a typed error on first use rather than being silently stripped into your response headers.
Verify tokens
Section titled “Verify tokens”const result = await verifyRakomiToken(token, { audience: 'https://api.example.com/mcp', // Mode A — see the threat model below // requiredTenantId: 'tenant_...', // Mode B pin, or an extra pin in Mode A // requiredClientId: 'cli_...', // optional client pin, either mode // clockTolerance: 30, // seconds, clamped to [0, 120]});if (result.ok) { // result.data is the same typed payload RakomiClient.verifyToken() returns} else { // result.error.code — see the error reference below}verifyRakomiToken() never throws — every failure, including an unreachable or malformed key
endpoint, is a { ok: false, error } result. It verifies the signature (RS256 only), issuer, audience,
expiry with a bounded maximum token age, required claims, and the platform revocation signal, applying
the same rules as RakomiClient.verifyToken(). Repeated calls share a cached key set per JWKS URL, so
verification does not refetch keys on every request.
Threat model: two verification modes
Section titled “Threat model: two verification modes”Rakomi issues two audience shapes, and your verifier MUST handle the difference deliberately.
When an OAuth client has resource indicators configured and user consent, its tokens carry your
resource identifier in aud (RFC 8707). Otherwise tokens carry the platform audience
(https://rakomi.com) — and a platform-audience token is, by itself, not bound to YOUR server.
| Question | Mode A — audience-bound | Mode B — platform audience + pin |
|---|---|---|
| When | The client sends resource-bound tokens (resource indicators enabled + consent) | Default tokens without resource binding |
| Your verifier MUST | Compare token aud to your resource identifier — byte-for-byte | Expect the platform audience AND pin tenant_id (mandatory) |
| Configuration | { audience: RESOURCE_ID } | { requiredTenantId: 'tenant_...' } |
| Residual risk | Cross-tenant reuse if several tenants share one resource identifier — add requiredTenantId | Any token minted for the pinned tenant verifies, regardless of which app it was issued to — add requiredClientId to narrow |
Prefer Mode A when the client can be configured for resource-bound tokens; treat Mode B as a compatibility mode, not the target.
Enabling Mode A
Section titled “Enabling Mode A”Mode A needs the OAuth client to send your resource identifier with the authorization request:
- Configure the allowed resource identifiers on the OAuth client (dashboard or management API).
- The client includes the
resourceparameter (RFC 8707) in its authorization and token requests. - The user consents; issued tokens then carry your resource identifier as
aud.
The equality chain
Section titled “The equality chain”Four values MUST be byte-for-byte identical:
PRM `resource` == RFC 8707 `resource` sent by the client == token `aud` == verify `audience`Define ONE canonical constant — lowercase scheme and host, no trailing slash, no fragment, avoid a
query component — and thread it through all four places. Verification compares literally, with no URL
normalization: …/mcp and …/mcp/ are different audiences, and a trailing-slash mismatch is the
classic cause of mysterious token/invalid_audience results.
Rules your verifier MUST apply (both modes)
Section titled “Rules your verifier MUST apply (both modes)”- Single-string
audonly. Rakomi emits a single-stringaud; the array form is rejected even when it contains the expected value (any-of array matching is a confusion vector). - Mode selection is by presence of
audience.{ audience: undefined, requiredTenantId }— for example an unset environment variable — deliberately verifies as Mode B, which stays fail-closed against replay. If youraudiencecomes from configuration, assert it is present at boot. - Multi-tenant resources: Mode A alone suffices only when the resource identifier is unique per
tenant. A multi-tenant server sharing one identifier MUST also pin
tenant_idper request — route and authorize per tenant from the verified payload’stenantId. There is no “accept any tenant” escape hatch, and none is planned. - Access tokens only. Rakomi access tokens carry the JWT header
typ: at+jwt(RFC 9068), andverifyRakomiToken()requires it — an ID-token-shaped JWT is rejected before any claim is trusted (cross-JWT confusion defense). - Key-bound tokens presented as plain Bearer are indistinguishable to this verifier. A token carrying a key-binding confirmation claim, replayed as a plain Bearer token, verifies like any other (residual; a rejection option is a possible future addition).
- Revocation: the platform publishes a revocation signal alongside its signing keys, and the helper enforces it from the cached key material — no extra network call, no API key. Combined with the bounded maximum token age, this caps how long any token can remain accepted after a platform-wide revocation. There is no per-token online revocation check in this standalone path — factor that into how long you cache your own authorization results.
- Key rotation is automatic. An unknown key id triggers one key refetch, then rejection if still unknown. A warm server also survives temporary key-endpoint outages by serving from its cache (availability at the cost of a bounded post-rotation acceptance window — an inherited trade-off). Never hand-pin individual keys.
These measures mitigate cross-application token replay when configured per Mode A or Mode B as described in this guide — they are not a blanket guarantee, and each mode’s residual risks above stay part of your own risk assessment.
Scopes
Section titled “Scopes”-
Advertise only scopes you define. Custom scopes are defined per tenant in the Rakomi custom-scope registry (dashboard and management API);
mcp:*and other Rakomi-reserved namespaces are never grantable to your users and must not appear in yourscopes_supportedor challenges. -
Parse the
scopeclaim correctly. It is a space-separated list — split and compare exact tokens. A substring check (scope.includes('read')) wrongly matchesreadagainstread:all:const scopes = new Set((payload.scopes ?? [])); // the SDK pre-splits into `scopes`const hasScope = scopes.has('notes:read'); // exact match, never includes() -
The helper does not check scopes — scope enforcement is your authorization decision (see the 403 row below).
Error handling reference
Section titled “Error handling reference”| Verify result code | HTTP status | Challenge |
|---|---|---|
any token/* code | 401 | buildChallenge({ resourceMetadataUrl, error: 'invalid_token' }) |
any jwks/* code (keys unreachable on a cold start) | 503 or 401 | no error code required; retry after keys are reachable |
| insufficient scope (your own check — the helper does not check scopes) | 403 | buildChallenge({ resourceMetadataUrl, error: 'insufficient_scope', scope: 'required:scope' }) |
| no token presented | 401 | buildChallenge({ resourceMetadataUrl }) — WITHOUT error (RFC 6750 §3.1) |
any config/* code | 500 + log | NEVER mapped into a challenge or response body — configuration details are server-internal |
Troubleshooting
Section titled “Troubleshooting”| Code | Likely cause | Operator action |
|---|---|---|
token/invalid_audience | Trailing-slash / case mismatch in the equality chain, or another resource’s token | Diff the four equality-chain values byte-for-byte |
token/tenant_mismatch | Another tenant’s token, or a wrong requiredTenantId pin | Confirm the tenant id this server should serve |
token/client_mismatch | Token issued to a different OAuth client, or a session token with no client id | Confirm requiredClientId; drop the pin if session tokens are legitimate here |
config/missing_pin | Called with neither audience nor requiredTenantId | Configure one of the two modes — verifying with neither would accept any app’s tokens |
config/invalid_url | jwksUrl / issuer override is not a valid https URL | Fix the override; it is validated before any network call |
config/invalid_option | audience, requiredTenantId or requiredClientId was provided but is empty (usually an env var that is set but empty) | Pass a real value or omit the option — an empty value is rejected loudly instead of silently changing the verification mode |
token/expired on fresh tokens | Server clock skew | Check NTP; raise clockTolerance cautiously (clamped at 120s — more tolerance widens the replay window for just-expired tokens) |
Verify configuration at boot, not on your first production 401. All config/* errors are
deterministic — a boot-time self-check (verify any well-formed string, assert the error is not
config/*) catches misconfiguration before traffic does.
Operational notes
Section titled “Operational notes”- The first verification fetches the signing keys (cold-start latency) — warm the cache at boot by verifying a dummy token once.
- A warm server keeps verifying through a temporary key-endpoint outage (cached keys); a cold one fails closed until keys can be fetched.
- On serverless/edge platforms the per-process key cache resets on every cold start — consider provisioned concurrency if cold-start latency matters.
- A flood of tokens with unknown key ids triggers a key refetch per verification attempt — rate-limit repeated 401s per client as basic hygiene.
- Caching your own verification results (short TTL) is your decision — it trades staleness against the revocation bound described above.
Claims you may rely on
Section titled “Claims you may rely on”At the time of writing (inspect the tokens you actually receive — claim availability evolves):
- Always present after successful verification:
userId,tenantId,iss,aud,exp,iat,jti, plusrolesandpermissionsarrays. - Machine-to-machine tokens (
client_credentialsgrant,isM2M: true): no custom or fine-grained-authorization claims of any kind. Not just emptyroles/permissionsarrays — the entire custom-claims mechanism (organization context, subscription/plan data, public metadata, minor-protection flags, assurance/credential data, or any other custom claim) never applies to a machine-issued token; noemailorsessionIdeither.clientId,scopes, andazpare present — those identify and scope the calling service rather than encode fine-grained authorization data, so they aren’t affected by this; a DPoP-bound token additionally carries acnfkey-binding claim, which is a cryptographic proof, not an authorization claim either. You must make authorization decisions for machine callers fromclientIdandscopesalone — an empty custom-claim set is a direction to build resource-server-side authorization, not license to skip it. Richer machine-to-machine claim mapping may be added in future — re-inspect tokens when you upgrade. - User-flow tokens:
emailandsessionIdpresent; custom public metadata appears only on user tokens. clientIdpresence depends on the issuance path — session-issued tokens carry no client id, so do not assume the full RFC 9068 profile on every token.
Data hygiene (GDPR): user-flow tokens carry personal data (email, session identifiers); verified
machine-to-machine tokens carry none. Never log raw bearer tokens — a token is both a credential and a
personal-data carrier; log the jti if you need correlation. Rely only on the claims you actually need.
The revocation signal plus the bounded token age give you an upper bound on how long a revoked token can
still verify — factor it into your own caching and retention decisions.
The SDK follows the published SDK support & lifecycle policy, so you know the support window of the dependency you adopt.
Wire it into an @rakomi/mcp host
Section titled “Wire it into an @rakomi/mcp host”If you host tools with @rakomi/mcp, its authenticator seam expects exactly what these helpers produce:
const authenticate = async (req): Promise<McpAuthResult> => { const token = req.authorizationHeader?.replace(/^Bearer /, ''); if (!token) return { ok: false, status: 401, error: 'invalid_token', wwwAuthenticate: buildChallenge({ resourceMetadataUrl: PRM_URL }) }; const result = await verifyRakomiToken(token, { audience: RESOURCE_ID }); if (!result.ok) return { ok: false, status: 401, error: 'invalid_token', wwwAuthenticate: buildChallenge({ resourceMetadataUrl: PRM_URL, error: 'invalid_token' }) }; return { ok: true, ctx: { tenantId: result.data.tenantId, userId: result.data.userId } };};See also the OAuth integration guide for the client side of the flow.
Verify your setup
Section titled “Verify your setup”Three copy-paste checks close the loop:
# 1. Your discovery document is served where clients will look for it:curl https://api.example.com/.well-known/oauth-protected-resource/mcp# expect: resource, authorization_servers, bearer_methods_supported
# 2. An unauthenticated request gets a challenge pointing at that document:curl -i https://api.example.com/mcp# expect: 401 with WWW-Authenticate: Bearer resource_metadata="..."
# 3. A real token round-trips:curl -H "Authorization: Bearer $TEST_TOKEN" https://api.example.com/mcp# expect: 200 (or your endpoint's success response)The middleware in Quick start wires all three helpers into this loop end-to-end —
including the 403 insufficient_scope branch for requests whose token verifies but lacks a scope
your endpoint requires.