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-demo-production-d421.up.railway.app/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-demo-production-d421.up.railway.app/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. Use it, fork it, ship it.

F
license - not found
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/CoreframeLabs/mcp-auth-template'

If you have feedback or need assistance with the MCP directory API, please join our Discord server