mcp-auth-template
README.md
# 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.
[](https://mcp-auth.coreframe-labs.dev/demo/)
[](https://github.com/CoreframeLabs/mcp-auth-template/actions/workflows/ci.yml)
**▶ Live demo:** **[mcp-auth.coreframe-labs.dev/demo/](https://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 →](https://www.coreframe-labs.dev/#data)**
---
## 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.
## 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`.
```bash
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/](https://mcp-auth.coreframe-labs.dev/demo/)**.
### Advanced — run the pieces separately
```bash
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:
```bash
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
```bash
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.
```ts
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
```bash
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:
```json
{
"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
```mermaid
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.
```bash
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 →](https://www.coreframe-labs.dev/#data)**
---
## Licence
MIT — see [LICENSE](LICENSE). Use it, fork it, ship it.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues