Secure an MCP Server with OAuth
JWT validation, WWW-Authenticate challenges, and protected resource metadata.
An MCP server built with mcp-router is an HTTP route, so
you secure it as the MCP specification expects: as an OAuth 2.0 protected
resource. A jwt-validate block in front of the router verifies the bearer
token and answers unauthenticated requests with a 401 plus a
WWW-Authenticate challenge. A second flow serves the protected resource
metadata document (RFC 9728), which
tells MCP clients which authorization server to run their OAuth flow against.
This page walks through contacts-mcp, an MCP server exposing a contacts
database (five SQL tools and four prompts) behind OAuth, deployed at
contacts.example.com with an OIDC issuer at auth.example.com.

The protected flow
The MCP flow chains jwt-validate before mcp-router. On a missing or
invalid token it stops the flow with a 401 and the router never runs. On
success it stores the verified claims in vars.jwt (and the subject in
vars.sub) and lets the message through.
service:
name: contacts-mcp
env:
- name: HTTP_PORT
default: "8080"
- name: SUPABASE_DSN
required: true
- name: OIDC_ISSUER
default: https://auth.example.com
- name: MCP_RESOURCE_URL
default: https://contacts.example.com/mcp
- name: RESOURCE_METADATA_URL
default: https://contacts.example.com/.well-known/oauth-protected-resource/mcp
resources:
templates:
- resource: prompts/add_contact.md
as: add_contact_prompt
- resource: prompts/find_contact.md
as: find_contact_prompt
- resource: prompts/update_contact_info.md
as: update_contact_prompt
- resource: prompts/directory_overview.md
as: directory_overview_prompt
- resource: templates/protected-resource-metadata.json
as: protected_resource_metadata
connectors:
- name: api
type: http
settings:
port: ${HTTP_PORT}
- name: db
type: database
settings:
driver: postgres
dsn: ${SUPABASE_DSN}
maxOpenConns: 5
connMaxLifetime: 5m
flows:
- name: mcp
source:
connector: api
type: http
settings:
path: /mcp
headers:
- Authorization
responseHeaders:
- WWW-Authenticate
process:
- type: jwt-validate
settings:
mode: discover
tokenHeader: Authorization
claimsVar: jwt
disableWwwAuthenticate: false
issuer: ${OIDC_ISSUER}
resourceMetadataUrl: ${RESOURCE_METADATA_URL}
- type: mcp-router
name: contacts-router
serverName: contacts
tools:
# ... five SQL-backed tools, shown below
prompts:
# ... four prompts, shown belowTwo source settings are load-bearing. The HTTP source only copies listed
request headers into vars, and jwt-validate reads the bearer token from the
Authorization variable, so without headers: [Authorization] every request
looks unauthenticated. A rejecting jwt-validate sets the challenge in
vars and the source only emits response headers it is told to, so without
responseHeaders: [WWW-Authenticate] clients get a bare 401 and never learn
how to authenticate.
What jwt-validate does
mode: discover resolves signing keys by OIDC discovery: it fetches
<issuer>/.well-known/openid-configuration and the JWKS it points at.
Alternatives are mode: jwks (a direct jwksUrl) and mode: inline (a PEM key
via publicKey or publicKeyResource). Keys are resolved lazily on the first
request, so an unreachable provider does not fail startup.
issuer is the expected iss claim and the discovery URL. Set audience too
when your authorization server stamps a resource-specific aud, so tokens
minted for another API cannot be replayed here. claimsVar: jwt stores the
verified claims map in vars.jwt and the subject in vars.sub, readable by
everything after the block, including every mcp-router tool branch
(vars.jwt.email, vars.sub, roles). resourceMetadataUrl adds a
resource_metadata="…" parameter to the challenge, which makes the endpoint an
MCP-compliant protected resource.
On rejection the block responds with status 401 (configurable via
rejectStatus), a {"error": "unauthorized"} body, and a challenge like:
WWW-Authenticate: Bearer realm="https://auth.example.com", resource_metadata="https://contacts.example.com/.well-known/oauth-protected-resource/mcp"A token that is present but invalid (expired, wrong audience, bad signature)
gets the same challenge with an extra error="invalid_token" parameter, per
RFC 6750.
The metadata flow
MCP clients follow the resource_metadata URL from the challenge to learn
where to get a token. A second, unprotected flow serves that document from a
JSON template:
- name: protected-resource-metadata
source:
connector: api
type: http
settings:
path: /.well-known/oauth-protected-resource/mcp
process:
- type: template-resource
name: render-metadata
settings:
id: protected_resource_metadata
rawBody: true
contentType: application/json{
"resource": "{{ env.MCP_RESOURCE_URL }}",
"authorization_servers": ["{{ env.OIDC_ISSUER }}"],
"bearer_methods_supported": ["header"]
}In the RFC 9728 shape, resource is the MCP server's own public /mcp URL and
authorization_servers names the OIDC issuer clients register with and request
tokens from. The path is scoped per the MCP spec: for a resource at
<origin>/mcp, clients look under /.well-known/oauth-protected-resource/mcp.
The handshake, end to end
When you add https://contacts.example.com/mcp to an MCP client like Claude:
The client POSTs to /mcp with no token. jwt-validate stops the flow
and responds 401 with the WWW-Authenticate challenge carrying the
resource_metadata URL.
The client fetches the metadata. It GETs
/.well-known/oauth-protected-resource/mcp and reads
authorization_servers: ["https://auth.example.com"].
The client runs OAuth against the issuer. It discovers the authorization
server's metadata, registers (dynamic client registration, when the issuer
supports it), and walks the user through the authorization flow, requesting a
token for the resource from the metadata document.
The client retries with a bearer token. jwt-validate verifies it against
the issuer's keys, sets vars.jwt and vars.sub, and the message reaches the
mcp-router; initialize, tools/list, and tools/call work normally from
here.
No token-handling code anywhere: the client, the authorization server, and two declarative blocks do it all.
The payload: SQL tools and prompts
Behind the auth wall, contacts-mcp is an ordinary mcp-router. Each of its
five tools is a single sql block against the db
postgres connector. Here is create_contact; the has() ternaries turn
optional MCP arguments into SQL NULLs:
tools:
- name: create_contact
description: Create a new contact. first_name is required; all other fields are
optional. Returns the created contact including its generated id.
inputSchema: |
{
"type": "object",
"required": ["first_name"],
"properties": {
"first_name": { "type": "string" },
"last_name": { "type": "string" },
"email": { "type": "string" },
"phone": { "type": "string" },
"company": { "type": "string" },
"notes": { "type": "string" }
}
}
process:
- type: sql
name: insert-contact
settings:
connector: db
single: true
query: >
insert into contacts (first_name, last_name, email, phone,
company, notes)
values ($1, $2, $3, $4, $5, $6)
returning *
args:
- body.first_name
- "has(body.last_name) ? body.last_name : null"
- "has(body.email) ? body.email : null"
- "has(body.phone) ? body.phone : null"
- "has(body.company) ? body.company : null"
- "has(body.notes) ? body.notes : null"The other four follow the same pattern:
| Tool | Query |
|---|---|
get_contact | select * from contacts where id = $1 with single: true; returns the contact or null. |
list_contacts | A paged ilike search across first_name, last_name, email, and company, with limit/offset defaulting to 50/0 via has() ternaries. |
update_contact | A partial update: coalesce($n, column) per field, so omitted arguments keep their current values; returning *. |
delete_contact | exec: true, returning { rowsAffected }. |
Four prompts (add_contact, find_contact, update_contact_info, and
directory_overview) are template resources that prime the client's model to
use the tools. prompts/add_contact.md takes a free-text details argument
and tells the model to extract the fields and call create_contact:
I'd like to add a new contact to the contacts database.
Here are the details I have:
{{ body.details }}
Please pull out the person's first name, last name, email, phone, company, and
any notes from that description, then call the `create_contact` tool to save
them. First name is required; if you can't find one, ask me before saving.
When it's done, show me the created contact (including its new id) and confirm
what you stored.Deploying against Supabase? Use the Session pooler DSN for
SUPABASE_DSN, not the direct db.<ref>.supabase.co host. The direct host
resolves to IPv6 only, and the runner has no IPv6 connectivity; the pooler
DSN works over IPv4.
Where to go next
- Expose an MCP Server: the
mcp-routerblock reference this page builds on. samples/jwt-validate.yamlin the repo is a minimal, runnable version of this pattern: one protected/meendpoint plus the metadata flow, ready to point at any OIDC issuer.- Validation and Auth: request validation patterns beyond JWTs.