MCP servers behind Schleuse
Model Context Protocol servers let an AI client — Claude, ChatGPT — call your tools. Anyone who can reach the endpoint can call them, so it needs authentication, and Schleuse can be the authorization server for it.
This needs no new Schleuse feature. Two environment variables and about twenty lines in your MCP server. This page is the whole recipe.
What you get
Your MCP server receives a signed token naming the user, bound to your server specifically, verifiable offline against Schleuse's public keys. No shared secrets, no callback to the IdP per request.
1. Register the AI client
Claude lets you enter a client id when adding a custom connector, so no dynamic registration is involved. Add it to NUXT_OIDC_CLIENTS_JSON:
{
"clientId": "claude-mcp",
"redirectUris": ["https://claude.ai/api/mcp/auth_callback"],
"grantTypes": ["authorization_code", "refresh_token"],
"scopes": ["openid", "email", "profile", "offline_access"],
"public": true
}public: true because the client holds no secret — PKCE protects the exchange, and Schleuse requires it (S256) for every client.
offline_access in the scope list matters: without a refresh token the user re-authorises whenever the access token expires.
2. Declare your MCP server as a resource
This is the step that is easy to miss, and nothing works without it:
NUXT_OIDC_RESOURCE_SERVERS_JSON='[{
"audience": "https://mcp.example.com/mcp",
"scopes": "openid email profile",
"accessTokenTTL": 86400
}]'The audience must be byte-identical to the URL the user types into Claude. A conformant MCP client sends resource=<that URL> on both the authorization and the token request; an audience Schleuse does not know is rejected with invalid_target, and the connection fails before your server sees anything.
The connecting OIDC client must also list that URL in its allowedResources (audiences are deny-by-default — a client without the field may request no resource at all):
{ "clientId": "claude", "…": "…", "allowedResources": ["https://mcp.example.com/mcp"] }What this buys you: the access token becomes a JWT with aud set to your server, instead of an opaque string you would have to introspect on every call.
accessTokenTTL is worth setting. The one-hour default is short for MCP — some clients fall back to a full re-authorisation instead of refreshing. A day is reasonable; the security anchors are refresh rotation and revocation, not a short access-token life.
3. Return 401 with a pointer
This is the bootstrap of the entire flow. Without this header no client ever attempts discovery, and the connection fails with nothing in your log:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"4. Serve the resource metadata
At the URL you just named:
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com/oidc"],
"scopes_supported": ["openid", "email", "profile"],
"bearer_methods_supported": ["header"]
}resource must match your MCP URL exactly, and authorization_servers is your NUXT_ISSUER. From here the client finds Schleuse on its own.
5. Verify the token
The whole server side, using jose:
import { createRemoteJWKSet, jwtVerify } from 'jose'
const ISSUER = 'https://auth.example.com/oidc'
const RESOURCE = 'https://mcp.example.com/mcp'
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'))
/** Returns the caller, or throws. */
export async function authenticate(authorizationHeader) {
const token = /^Bearer (.+)$/.exec(authorizationHeader ?? '')?.[1]
if (!token) throw new Error('no bearer token')
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: RESOURCE,
})
return { userId: payload.sub, clientId: payload.client_id, scope: payload.scope }
}createRemoteJWKSet caches the keys and refetches on rotation, so this makes no network call per request.
Do not omit audience
Without it your server accepts any token Schleuse issued — including one minted for a different application entirely. That is the difference between "authenticated" and "authenticated for me", and the MCP specification makes the check mandatory. Everything else on this page is convenience; this line is the security boundary.
A caller with no user
The same check accepts a service token — an MCP server or API is often called by a backend rather than a person. Such a token is minted with the client_credentials grant and carries no account: its sub is the client_id, and /oidc/userinfo refuses it. So key your authorisation off payload.scope and payload.client_id, and only treat payload.sub as a user when it differs from client_id:
const isService = payload.sub === payload.client_idSee Service clients.
What Schleuse does not do
- No proxying. Requests go straight to your server; Schleuse only issues the token.
- No tool filtering. Which tools a caller may use is your server's decision.
- No role enforcement. Roles are available — call
GET /api/apps/roleswith your client credentials to look up whatpayload.subholds — but nothing is applied for you.
Requirements and limits
- HTTPS. Both Schleuse and your MCP server. Redirect URIs must be HTTPS or loopback.
- Reachability. Anthropic's discovery traffic comes from
160.79.104.0/21. A WAF in front of the IdP breaks the flow even when the MCP server itself is reachable. - Latency. Claude allows about ten seconds for discovery and token requests. A cold-starting container can exceed that;
/api/readymakes a good health check — it stays503until the startup migrations are done, so a gate built on it never lets Claude in too early.
Discovery paths
Schleuse's issuer carries a path (/oidc), so RFC 8414 puts that component after the well-known segment. All of these return the same document:
/.well-known/oauth-authorization-server/oidc
/.well-known/openid-configuration/oidc
/.well-known/openid-configuration
/oidc/.well-known/openid-configurationA client that probes in the specified order finds Schleuse on its first attempt. /.well-known/oauth-authorization-server without the suffix deliberately returns 404 — it describes a root issuer, which Schleuse never has.
Gateways that cannot send PKCE
Some MCP gateways act as an OIDC client toward the IdP and do not send a code_challenge on that leg — mcp-auth-proxy is one. Schleuse enforces PKCE for confidential clients too, so such a gateway is rejected with Authorization Server policy requires PKCE to be used for this request.
Set pkceExempt: true on that client, having read what the exemption costs: if the gateway sends no nonce either, it has no protection against authorization-code injection on its redirect. Schleuse warns about the exemption on every start.
When you outgrow this
The recipe repeats the twenty lines per MCP server. At three or four, or when an upstream cannot host the check itself, a small gateway in front of them — one token check, identity passed back as signed headers — is the better shape. It needs nothing from Schleuse beyond what is on this page.
Clients that cannot be pre-registered (Claude Code identifies itself with a metadata document rather than a client id) would need dynamic registration or CIMD, neither of which Schleuse offers today.