Run your own OAuth server: Ory Hydra in front of MCP
No Auth0, no SaaS identity provider — a self-hosted Hydra instance that issues connector tokens, with the DCR response quirk that fails Claude's schema validation and the tiny proxy that fixes it.
hydra · updated 2026-08-22
The Auth0 proxy pattern rents the authorization server. This guide is the other path, taken from a production deployment: run the OAuth server yourself with Ory Hydra, so tokens, client records, and user consent never leave your box. Hydra speaks everything an MCP connector needs — RFC 8414 discovery, dynamic client registration, PKCE, JWT access tokens — and it does nothing else: no user store, no login UI. You keep those in your own app, which is the point.
The shape
claude.ai / ChatGPT / Grok
│
├─ https://auth.yourdomain.com ← Caddy → Hydra public (127.0.0.1:4444)
│ ├─ /.well-known/* (discovery, JWKS)
│ ├─ /oauth2/auth, /oauth2/token
│ └─ /oauth2/register* ──► DCR scrub proxy (127.0.0.1:4480) ──► Hydra
│
├─ browser redirect ──► https://yourapp.com/oauth/login + /oauth/consent
│ (your app; Hydra delegates identity to it)
│
└─ https://mcp.yourdomain.com/mcp ← your MCP server
validates the JWT offline against auth.yourdomain.com/.well-known/jwks.json
Hydra’s admin API (:4445) is never proxied — it stays on loopback,
reachable only by your app’s consent handler.
Hydra configuration that matters
The load-bearing pieces of hydra.yml, each tied to a connector
requirement:
serve:
public: { port: 4444, host: 127.0.0.1 } # Caddy fronts this
admin: { port: 4445, host: 127.0.0.1 } # never proxied
urls:
self: { issuer: https://auth.yourdomain.com }
login: https://yourapp.com/oauth/login # your app authenticates users
consent: https://yourapp.com/oauth/consent # your app approves scopes
oidc:
dynamic_client_registration: # without this, /oauth2/register answers
enabled: true # "Dynamic registration is not enabled"
default_scope: [yourapp:read] # whitelist for self-registered clients
strategies:
access_token: jwt # lets the MCP server validate offline
oauth2:
pkce:
enforced_for_public_clients: true # every MCP connector is public
Two deployment notes from running this in production: secrets and the DSN
come from environment variables, not the YAML (Hydra maps config paths to
env vars and does not interpolate ${...}), and SQLite on local disk is a
perfectly good datastore for a single-node authorization server — it also
keeps token storage out of any database your tenants can reach.
The DCR quirk that fails Claude’s validation
This is the one that costs people a day. Hydra’s registration response
includes every optional client field — around 13 keys set to null (the
*_lifespan family, contacts) and 5 set to "" (client_uri,
logo_uri, owner, policy_uri, tos_uri). Claude’s connector validates
that response against a strict schema: null fails “expected string”, and
"" fails the URL format check. Registration is rejected and the flow
never starts — while every curl test you run looks fine.
The fix is a deliberately tiny proxy in front of /oauth2/register that
drops keys whose value is null or an empty string and forwards everything
else untouched:
EMPTY_SCALARS = (None, "")
def scrub(payload):
if isinstance(payload, list):
return [scrub(item) for item in payload]
if not isinstance(payload, dict):
return payload
return {
key: scrub(value)
for key, value in payload.items()
if not any(value is e or value == e for e in EMPTY_SCALARS)
or isinstance(value, (list, dict))
}
Keep the rule general (drop empty scalars) rather than listing field names —
a field list misses every new *_lifespan key the next Hydra release adds.
Empty containers survive: audience: [] and metadata: {} are valid for
their types, and dropping them changes meaning. The proxy doesn’t
authenticate; Hydra’s own registration policy is the control.
Caddy routing
auth.yourdomain.com {
handle /oauth2/register* {
reverse_proxy 127.0.0.1:4480 # the scrub proxy
}
handle {
reverse_proxy 127.0.0.1:4444 # Hydra public
}
}
mcp.yourdomain.com {
reverse_proxy 127.0.0.1:8020 # your MCP server
}
Caddy provisions the certificates; the DNS pre-flight rules from the failure map still apply.
Token validation at the MCP server
With access_token: jwt, your MCP server validates bearer tokens offline
against https://auth.yourdomain.com/.well-known/jwks.json — signature,
iss, exp, and audience — and answers unauthenticated requests with:
WWW-Authenticate: Bearer resource_metadata="https://mcp.yourdomain.com/.well-known/oauth-protected-resource"
The protected-resource document points authorization_servers at
https://auth.yourdomain.com. From there, discovery is Hydra’s problem —
which is the whole appeal.
What you give up vs. Auth0
You own uptime, backups, and upgrades of the authorization server, and you
must build the login and consent pages (Hydra redirects to them; a form and
two PUTs to the admin API accepting the login/consent challenges). In
exchange: no per-user pricing, no tenant limits, tokens and client secrets
on your disk, and one fewer external dependency in the auth path. Verify
with the same four curls as always — the
connector checklist doesn’t care who
issues the tokens.
Metadata
- Commit
- 650e1b8
- Browser
- —
- Current Time
- —
- Dimensions
- —
- Source
- guides
- Last Updated
- 2026-08-22