Skip to content

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:

  1. Serve discovery metadata at your well-known URL with buildProtectedResourceMetadata() (RFC 9728 §2).
  2. Challenge unauthenticated requests with buildChallenge() (RFC 9728 §5.1 / RFC 6750).
  3. 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.

  1. Install the SDK: npm install @rakomi/node.

  2. Pick ONE canonical resource identifier and thread it everywhere (see the equality chain below).

  3. 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();
    });

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.

MCP clients construct the metadata URL from your resource identifier by inserting the path AFTER the well-known segment (RFC 9728 §3):

Resource identifierMetadata must be served at
https://api.example.comhttps://api.example.com/.well-known/oauth-protected-resource
https://api.example.com/mcphttps://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.

  • Constants only, never request-derived values. Build the document once at boot from configuration. Deriving any URL in it from the incoming Host header would let a spoofed header advertise an attacker’s authorization server to your clients — a token-phishing pivot. The same rule applies to the jwksUrl and issuer verification 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=3600 is a sensible default.

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"'
  • error is 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 error entirely (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.
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.

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.

QuestionMode A — audience-boundMode B — platform audience + pin
WhenThe client sends resource-bound tokens (resource indicators enabled + consent)Default tokens without resource binding
Your verifier MUSTCompare token aud to your resource identifier — byte-for-byteExpect the platform audience AND pin tenant_id (mandatory)
Configuration{ audience: RESOURCE_ID }{ requiredTenantId: 'tenant_...' }
Residual riskCross-tenant reuse if several tenants share one resource identifier — add requiredTenantIdAny 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.

Mode A needs the OAuth client to send your resource identifier with the authorization request:

  1. Configure the allowed resource identifiers on the OAuth client (dashboard or management API).
  2. The client includes the resource parameter (RFC 8707) in its authorization and token requests.
  3. The user consents; issued tokens then carry your resource identifier as aud.

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 aud only. Rakomi emits a single-string aud; 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 your audience comes 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_id per request — route and authorize per tenant from the verified payload’s tenantId. 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), and verifyRakomiToken() 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.

  • 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 your scopes_supported or challenges.

  • Parse the scope claim correctly. It is a space-separated list — split and compare exact tokens. A substring check (scope.includes('read')) wrongly matches read against read: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).

Verify result codeHTTP statusChallenge
any token/* code401buildChallenge({ resourceMetadataUrl, error: 'invalid_token' })
any jwks/* code (keys unreachable on a cold start)503 or 401no error code required; retry after keys are reachable
insufficient scope (your own check — the helper does not check scopes)403buildChallenge({ resourceMetadataUrl, error: 'insufficient_scope', scope: 'required:scope' })
no token presented401buildChallenge({ resourceMetadataUrl }) — WITHOUT error (RFC 6750 §3.1)
any config/* code500 + logNEVER mapped into a challenge or response body — configuration details are server-internal
CodeLikely causeOperator action
token/invalid_audienceTrailing-slash / case mismatch in the equality chain, or another resource’s tokenDiff the four equality-chain values byte-for-byte
token/tenant_mismatchAnother tenant’s token, or a wrong requiredTenantId pinConfirm the tenant id this server should serve
token/client_mismatchToken issued to a different OAuth client, or a session token with no client idConfirm requiredClientId; drop the pin if session tokens are legitimate here
config/missing_pinCalled with neither audience nor requiredTenantIdConfigure one of the two modes — verifying with neither would accept any app’s tokens
config/invalid_urljwksUrl / issuer override is not a valid https URLFix the override; it is validated before any network call
config/invalid_optionaudience, 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 tokensServer clock skewCheck 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.

  • 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.

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, plus roles and permissions arrays.
  • Machine-to-machine tokens (client_credentials grant, isM2M: true): no custom or fine-grained-authorization claims of any kind. Not just empty roles/permissions arrays — 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; no email or sessionId either. clientId, scopes, and azp are 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 a cnf key-binding claim, which is a cryptographic proof, not an authorization claim either. You must make authorization decisions for machine callers from clientId and scopes alone — 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: email and sessionId present; custom public metadata appears only on user tokens.
  • clientId presence 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.

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.

Three copy-paste checks close the loop:

Terminal window
# 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.