quickoauth
← Guides

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