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.
Updated
On this page
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, offline_access] # defaults, enforced by consent
strategies:
access_token: jwt # lets the MCP server validate offline
ttl:
access_token: 1h
refresh_token: 8760h # 365 days
auth_code: 10m
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, and it also
keeps token storage out of any database your tenants can reach.
Keep connectors signed in with refresh tokens
The one-hour access token is renewed using a refresh token with a 365-day lifetime. A longer browser session does not change these token lifetimes. Hydra rotates refresh tokens; clients must save each replacement and renew access tokens automatically.
Register the client with grant_types: ["authorization_code", "refresh_token"]
and request offline_access alongside the MCP resource scopes during
authorization. In the consent handler, preserve requested offline_access
in grant_scope for every consent option. Filtering grants down to only
application scopes drops the permission Hydra needs to issue refresh tokens.
Never add offline access when it was not requested, and continue checking
resource ownership and allowed permissions separately. default_scope sets
registration defaults; it does not replace those permission checks.
Register the intended MCP audience in the client’s audience list before
adding it to grant_access_token_audience at consent. Hydra checks that
allowlist on refresh: an initial code exchange can succeed while refresh
fails with an unregistered audience. Configure registration to match the
audience your resource server validates.
Verify a real code exchange returns both refresh_token and expires_in close to 3600 seconds.
Then send grant_type=refresh_token to the token endpoint without a browser
login, save the rotated token, and make an authenticated MCP call using the
new access token. Test consent with and without offline_access, including
each resource selection your app supports. A connector that never requests
offline access or does not refresh automatically still needs reconnection.
For an existing installation, deploy the consent change, install the updated
Hydra configuration, and restart both services. Check for TTL_REFRESH_TOKEN
and per-client authorization_code_grant_refresh_token_lifespan /
refresh_token_grant_refresh_token_lifespan overrides. Reconnect once to get
the new grant; update or recreate registrations missing refresh support.
Existing tokens are not retroactively extended.
See Hydra’s configuration reference for token lifetimes and client defaults, and Ory’s offline consent example for refresh-token issuance.
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.
Next steps
Finishing checklist for a Hydra deployment:
- Confirm the admin port (4445) is loopback-only from outside: the connector checklist’s probes must never reach it.
- Run the four curls from the connector checklist, then connect in claude.ai.
- Add ChatGPT and Grok with one server, three connectors; the DCR scrub proxy matters most for ChatGPT.
The Hydra prompt (self-host-hydra-oauth) automates the full stack. If self-hosting turns out to be more ceremony than you want, the hosted walkthroughs (Auth0, Stytch, Supabase) reach the same contract with less to operate.
Put it into practice
Self-host the OAuth server with Ory Hydra
Self-host MCP OAuth with Hydra, DCR, offline JWT validation, and rotating refresh tokens valid for 365 days.
Preview prompt
Set up a self-hosted OAuth 2.1 authorization server for my MCP server using Ory Hydra, so Claude, ChatGPT, and Grok connectors can register and authenticate without any external identity provider.
Target layout: Hydra serves https://auth.MY_DOMAIN (public API on 127.0.0.1:4444, admin API on 127.0.0.1:4445 — the admin port must NEVER be proxied publicly); my MCP server stays at https://mcp.MY_DOMAIN/mcp; my app at https://MY_APP_DOMAIN provides the login and consent pages. TLS terminates at Caddy.
Implement, in order:
1. Install Hydra (current stable) with a hydra.yml configured for: issuer https://auth.MY_DOMAIN; urls.login and urls.consent pointing at my app's /oauth/login and /oauth/consent; oidc.dynamic_client_registration.enabled: true with default_scope including my resource scopes and offline_access; strategies.access_token: jwt; oauth2.pkce.enforced_for_public_clients: true; ttl.access_token: 1h; ttl.refresh_token: 8760h (365 days); ttl.auth_code: 10m. Keep refresh-token rotation enabled. default_scope supplies defaults when registration omits scopes; enforce permissions in consent. DSN and system secrets come from environment variables (Hydra does not interpolate ${...} in YAML). SQLite on local disk is fine for a single node.
2. Build the DCR scrub proxy: a tiny HTTP service on 127.0.0.1:4480 that forwards /oauth2/register* to Hydra and recursively drops response keys whose value is null or an empty string (keep empty lists/dicts — they are valid). This is required: Hydra's registration response includes null lifespan fields and empty-string URI fields, and Claude's connector rejects the response against a strict schema before the flow starts. Use a general drop-empty-scalars rule, not a field-name list.
3. Caddy: auth.MY_DOMAIN routes /oauth2/register* to the scrub proxy and everything else to 127.0.0.1:4444; mcp.MY_DOMAIN routes to my MCP server. No route may fall through to a default page on /.well-known paths.
4. Login + consent in my app: handle Hydra's login_challenge and consent_challenge via the admin API on 127.0.0.1:4445 (accept login after authenticating the user; accept consent with the granted scopes). Preserve requested offline_access in grant_scope for EVERY consent option, including per-resource, account-only, and all-resource grants. Filter resource permissions separately so this scope cannot widen access. Do not grant offline_access if it was not requested. Clients must register grant_types containing authorization_code and refresh_token, request offline_access during authorization, and persist each rotated refresh token. Browser session duration is a separate setting; extending it does not extend MCP tokens. My app's stack is: [fill in].
5. MCP server auth: validate bearer JWTs offline against https://auth.MY_DOMAIN/.well-known/jwks.json (signature, iss, exp, audience) and answer unauthenticated /mcp requests with 401 and WWW-Authenticate: Bearer resource_metadata="https://mcp.MY_DOMAIN/.well-known/oauth-protected-resource". Serve that protected-resource document with authorization_servers: ["https://auth.MY_DOMAIN"]. Ensure the intended MCP audience is allowed in the client's registered audience before adding it to grant_access_token_audience: Hydra rejects refresh requests with an unregistered audience, even when the initial code exchange succeeds. Configure registration accordingly and test both exchanges.
6. Run Hydra, the scrub proxy, and my MCP server as systemd services (or my platform's equivalent) so they survive reboots.
Then verify: curl Hydra's /.well-known/oauth-authorization-server and jwks.json through Caddy; POST a test registration to /oauth2/register and confirm the response contains no null or "" values; run the 401 probe and protected-resource check against mcp.MY_DOMAIN; finally add the connector in claude.ai and confirm the login page is mine, not a third party's. Complete a real authorization-code exchange and assert the response includes a refresh_token and expires_in close to 3600 seconds. Exchange that token with grant_type=refresh_token without a browser login, confirm rotation, save the replacement, and use the new access token for an authenticated MCP call. Test that consent preserves offline_access only when requested and still enforces resource ownership and scopes.
For an existing deployment, install the updated Hydra config and restart Hydra as well as the consent app. Check TTL_REFRESH_TOKEN and per-client authorization_code_grant_refresh_token_lifespan / refresh_token_grant_refresh_token_lifespan overrides. Reconnect once to obtain the new grant; update or recreate clients missing refresh_token grant support. Existing tokens are not retroactively extended. Report any connector that does not request offline_access or automatically refresh: it cannot stay connected for a year just because the server allows it. Keep access tokens at one hour; long-term access comes from renewable refresh tokens.Use this site from your agent
Add this URL as a connector in Claude, ChatGPT, or Grok. No sign-in required.
https://quickoauth.com/mcp