Skip to main content
Glama
CoreframeLabs

mcp-auth-template

mcp-auth-template

A secure remote Model Context Protocol server template. Streamable HTTP transport, OAuth 2.1, Client ID Metadata Documents, and constant-time credential verification — with a mock authorization server so the whole flow runs on your laptop with no external accounts.

Live Demo

CI

▶ Live demo: mcp-auth.coreframe-labs.dev/demo/ — run the real auth flow in your browser and watch each defence accept or reject a request.

Most MCP servers today run over stdio on localhost with no authentication at all. The moment one becomes remote, it needs a real authorization story. This template is that story, built to be read: every security decision has a comment explaining why, including the places where a defence is deliberately not applied.

Scaling your company's AI integrations? Coreframe Labs helps venture-backed teams and enterprises build secure, production-grade custom software architectures. Let's design your agent infrastructure safely — talk to our Core Architects →


Why this exists

AI-assisted development produces code that passes its tests and still ships security holes, because tests assert behaviour and attackers exploit properties tests never check. Three that recur:

  • Naive credential comparison. if (token === expected) returns as soon as two bytes differ. That difference is measurable, and it turns a secret into something an attacker can learn a byte at a time rather than guess whole.

  • Secrets stored in recoverable form. A leaked config file or docker inspect hands over working credentials.

  • Endpoints that answer questions they were not asked. A token endpoint that responds faster for an unknown client than a wrong password is an enumeration oracle for your customer list.

None of those fail a unit test. All three are addressed here explicitly, and the reasoning is in the code.

Related MCP server: Model Context Protocol Template

What's implemented

Capability

Status

MCP Streamable HTTP transport (POST/GET/DELETE, SSE, sessions)

official @modelcontextprotocol/sdk

OAuth 2.1 client_credentials grant

yes

private_key_jwt client auth (RFC 7523)

yes

client_secret_basic / client_secret_post client auth

yes, constant-time

Client secrets stored as scrypt hashes

yes

Client ID Metadata Documents + SSRF hardening

yes

RFC 8707 resource indicators (audience-bound tokens)

yes

RFC 9728 protected resource metadata

yes

Client assertion replay prevention (jti)

yes, in-memory

Scope narrowing at the authorization server

yes

Session ownership binding

yes

Interactive demo with tamper scenarios

yes

authorization_code / PKCE / refresh tokens

no — returns 501

Token persistence, revocation, distributed state

no


Quick start — 3 steps

Requires Node 22+. No database, no Docker, no accounts, no .env.

npm install                              # 1. install
npm run dev                              # 2. run the all-in-one demo (:3000)
open http://localhost:3000/demo/         # 3. open it

That's the whole thing. npm run dev boots the authorization server, the MCP resource server and the interactive UI in one process. Click through the scenarios — each runs the real flow against the server and shows the actual HTTP exchange:

Scenario

Outcome

Valid client, correct everything

token issued, tools callable

Assertion signed with an unpublished key

401 at the token endpoint

Assertion addressed to a different token endpoint

401 at the token endpoint

Expired assertion

401 at the token endpoint

The same assertion presented twice

second exchange rejected

Valid token missing mcp:tools

403 at the MCP server

Token with an edited payload

401 at the MCP server

No Authorization header

401 + WWW-Authenticate

Prefer not to install anything? The same UI is live at mcp-auth.coreframe-labs.dev/demo/.

Advanced — run the pieces separately

npm run dev:as          # mock authorization server on :4000
npm run dev:rs          # MCP resource server on :3000 (needs the AS running)
npm run demo            # scripted client against them, prints every step

Confirm the boundary is closed:

curl -i -X POST http://localhost:3000/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

You get a 401 whose WWW-Authenticate header names the metadata document. That is not a failure — it is how a compliant MCP client discovers where to get a token.

You cannot complete the full flow with curl alone: a client must publish a metadata document at its own client_id URL for the authorization server to fetch. npm run demo (and the browser demo) stands one up for you.

Verify everything

npm test            # 132 tests
npm run typecheck
npm run build

Constant-time credential verification

The template compares secrets in constant time where a secret is actually being compared, and documents where it deliberately does not. Both halves matter — blanket application is cargo cult and hides where the real boundary is.

Where it is used

src/auth/client-secret.ts — OAuth client secrets. The primary path.

const candidate = await scrypt(presented, parsed.salt, parsed.key.length, params);
return timingSafeEqual(candidate, parsed.key);

Two independent properties:

  1. Secrets are never stored recoverably. CLIENT_SECRETS holds salted scrypt derivations. A leaked config yields no working credential.

  2. Comparison is constant-time. It runs over fixed-width derived keys, so secrets of differing lengths compare safely — crypto.timingSafeEqual throws on length mismatch, and the obvious guard (if (a.length !== b.length) return false) leaks the secret's length.

Unknown clients are verified against a decoy hash, so "no such client" costs the same as "wrong secret". Without that, the endpoint enumerates your client list by latency alone. There is a test asserting both responses are byte-identical.

src/util/safe-compare.ts covers the dev/CI static-token mode, HMAC-ing both sides under a per-process random key before comparing.

Where it is deliberately not used

  • JWT signature verification. jose already does this correctly. Reimplementing it here would be a downgrade, not an improvement.

  • Issuer, audience, scope, client_id. None are secrets. A timing oracle on a value the attacker already supplied reveals nothing.

What does more work than constant-time comparison in this codebase is uniform failure responses: expired, wrong-audience and bad-signature all collapse to one invalid_token message, so probing yields no signal. A test asserts the responses are byte-identical.

Registering a client secret

npm run hash-secret -- https://client.example/id

Prints the secret once (give it to the client) and the hash (put it in CLIENT_SECRETS). The server refuses to start if it finds a plaintext value.


Client ID Metadata Documents

Instead of pre-registering clients, a client_id is an HTTPS URL serving a document describing the client:

{
  "client_id": "https://client.example/mcp-client.json",
  "client_name": "Example Client",
  "token_endpoint_auth_method": "private_key_jwt",
  "grant_types": ["client_credentials"],
  "scope": "mcp:tools",
  "jwks": { "keys": [{ "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }] }
}

The validation flow

sequenceDiagram
    autonumber
    participant C as MCP Client
    participant AS as Authorization Server
    participant CIMD as Client's Metadata URL
    participant RS as MCP Resource Server

    C->>AS: POST /token (client_credentials + private_key_jwt)
    Note over AS: the assertion's iss claim IS the client_id (an https URL)
    AS->>AS: allowlist + SSRF guards on client_id
    AS->>CIMD: GET client_id
    CIMD-->>AS: metadata document (jwks, auth method)
    Note over AS: document.client_id must equal the fetched URL
    Note over AS: no redirects, no private IPs, size and type limits
    AS->>AS: verify assertion signature vs jwks — check aud, exp, jti
    AS-->>C: access_token (aud = resource, scope narrowed)
    C->>RS: POST /mcp (Authorization: Bearer TOKEN)
    RS->>RS: verify JWT iss, aud, scope, signature via JWKS
    RS-->>C: 200 JSON-RPC result — or 401 / 403

This makes the authorization server an HTTP client pointed at an attacker-supplied URL — a textbook SSRF sink. src/auth/cimd.ts enforces:

  • HTTPS only (relaxed solely for loopback dev via CIMD_ALLOW_INSECURE)

  • no fragment, no embedded credentials in the URL

  • DNS resolution checked against loopback, RFC 1918, link-local (including the 169.254.169.254 cloud metadata address), CGNAT, multicast, IPv6 equivalents

  • redirects refused outright rather than re-validated per hop

  • application/json required; 64 KiB streamed cap; 5s timeout

  • document.client_id must equal the URL it was fetched from — otherwise any host could vouch for another host's identity

  • TTL cache over successes and failures; negative caching stops a hostile client_id becoming a traffic amplifier

  • an optional allowlist, which any publicly reachable deployment must set — open resolution means anyone can make your server fetch a URL and report whether it worked

Known limitation: DNS rebinding

The guard resolves the hostname, then fetch resolves it again. An entry that changes between those lookups defeats it. Closing this needs IP pinning with a custom agent, or an egress allowlist proxy. Not implemented here — documented rather than hidden.


Session security

Streamable HTTP sessions are identified by an Mcp-Session-Id header, which is effectively a bearer credential for an already-authenticated stream. Each session records the client that opened it; presenting someone else's session id returns 404 — identical to a nonexistent session, so the status code cannot enumerate live sessions.


Configuration

Environment-driven, validated by Zod at boot (src/config.ts). Invalid config fails immediately and prints every problem at once, naming the offending variables but never their values — those are exactly where secrets live.

See .env.example for every option. There are no credentials in this repository; the mock AS generates an ephemeral ES256 keypair per boot.


Deploying

The demo (Railway / Render)

src/demo/index.ts serves the AS, the MCP server and the demo UI on one origin.

docker build -t mcp-auth-demo .
docker run -p 3000:3000 -e PUBLIC_URL=https://your-domain mcp-auth-demo

Railway: point it at this repo (railway.json is included), set PUBLIC_URL to the public origin, then add a CNAME for your subdomain. Render: use the included render.yaml.

PUBLIC_URL must be the externally reachable origin. It becomes the OAuth issuer, the RFC 8707 resource identifier, and the client_id URLs the AS dereferences. Wrong value → every token fails its audience check. The server refuses to start on a non-HTTPS PUBLIC_URL outside loopback.

Single instance only. Sessions and the jti replay guard are per-process; a second replica splits them and produces intermittent failures.

Vercel: not without changes

Blocker

Effect

Sessions in an in-memory Map

invocations do not share memory; follow-up requests 404

SSE streams held open

functions have a hard max duration

Mock AS mints an ephemeral keypair per boot

each cold start publishes different JWKS; tokens fail intermittently

jti replay guard per-process

replay protection silently stops working

Stateless mode (sessionIdGenerator: undefined) plus a real authorization server would work. For the full stateful behaviour, use a container host.


Security posture

  • CI runs typecheck, tests, build, npm audit, gitleaks secret scanning and CodeQL (security-extended) on every push and weekly.

  • No credentials in the repository; .env is gitignored, .env.example is not.

  • CLAUDE.md documents the conventions that keep this true.

The mock authorization server is a mock

In-memory state, ephemeral keys, no persistence, no revocation, no rate limiting on the standalone entrypoint. It exists so the flow runs locally. Do not deploy it as a real authorization server. For production, point MCP_ISSUER_URL and MCP_JWKS_URI at a real one; the resource server does not care who issued the token as long as it verifies.

Dependency advisory

npm audit reports a moderate advisory (GHSA-frvp-7c67-39w9) against @hono/node-server, transitively via @modelcontextprotocol/sdk: a path traversal in serve-static on Windows. This project serves no static files through Hono, so the path is unreachable. npm audit fix --force would downgrade the SDK to 1.24.3, judged the worse trade. CI fails on high/critical only. Revisit when the SDK bumps its dependency.


Project layout

src/
  app.ts                     MCP resource server (Express)
  config.ts                  Zod-validated environment config
  auth/
    cimd.ts                  CIMD resolution + SSRF hardening
    client-authentication.ts parsing of RFC 6749 client auth
    client-secret.ts         scrypt hashing + constant-time verification
    verifiers.ts             access token verification (JWT + static)
  mcp/server.ts              tool definitions
  mock-as/
    app.ts                   authorization server
    verify-client.ts         proves client identity per mechanism
  demo/
    server.ts                all-in-one demo (AS + RS + UI)
    scenarios.ts             the eight runnable scenarios
    page.ts                  self-contained demo UI
    public-url.ts            robust PUBLIC_URL resolution
  util/safe-compare.ts       constant-time string comparison
test/                        132 tests
scripts/
  demo-client.ts             scripted end-to-end client
  hash-secret.ts             client secret generation

Work with Coreframe Labs

Scaling your company's AI integrations? We help venture-backed teams and enterprises build secure, production-grade custom software architectures. Let's design your agent infrastructure safely.

Talk to our Core Architects →


Licence

MIT — see LICENSE. Use it, fork it, ship it.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    A comprehensive Model Context Protocol server template that implements HTTP-based transport with OAuth proxy for third-party authorization servers like Auth0, enabling AI tools to securely connect while supporting Dynamic Application Registration.
    28 npm
    7
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    A production-ready MCP server template that connects LLMs and AI agents to external data, tools, and services with built-in OAuth 2.1 authentication, Redis-backed session management, and a modular tools engine.
    1
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    Remote MCP server with built-in OAuth authentication via Cloudflare Access, enabling secure tool usage like math and image generation with user-based access control.
    -