Auth0 as the authorization server
This recipe configures Auth0 as the authorization server for Kozou’s OAuth resource-server mode. Auth0 is the low-friction SaaS choice: it interprets RFC 8707 resource= parameters natively, so the audience side needs no mappers at all — the effort moves to client registration instead.
Step 1: define the API
Section titled “Step 1: define the API”Create an API (Applications → APIs → Create API) whose identifier is exactly your resource URI, e.g. https://mcp.example.com/mcp.
This is the piece Keycloak needs mappers for and Auth0 gives you for free: MCP clients send resource=<your resource URI> in their authorization and token requests, Auth0 treats it as the audience (tenants default to resource_parameter_profile: "audience"), and the issued JWT carries the right aud with no further setup. Kozou’s strict audience check then passes as-is.
On the API:
- Add the two permissions (scopes):
mcp:describeandmcp:execute. - Enable Allow Offline Access, so refresh tokens can be issued for it.
- Note the consent screen renders text generated from the scope names — the description fields are not shown there, so pick scope names you are happy for users to read.
Step 2: emit the role claim with a post-login Action
Section titled “Step 2: emit the role claim with a post-login Action”Auth0 does not map user metadata into access tokens by itself; a small post-login Action (Actions → Library → Build custom) does it:
exports.onExecutePostLogin = async (event, api) => { const role = event.user.app_metadata?.app_role; if (role) { api.accessToken.setCustomClaim('role', role); }};Attach it to the Login flow. Two things to know:
- Do not use an OIDC-registered standard claim name. Auth0 silently drops
setCustomClaimcalls for names likepreferred_username— no error, the claim just never appears. A plain custom name (role, as Kozou expects by default) works; so do namespaced URLs. - The Action deliberately sets nothing when
app_roleis absent. The token then arrives at Kozou without a role claim and is rejected — which is exactly the intended behavior for users nobody has authorized yet. Don’t “fix” it with a fallback role; that would grant database access to every authenticated user.
Step 3: assign roles to users
Section titled “Step 3: assign roles to users”Set each user’s app_metadata.app_role to the PostgreSQL role they should assume (User Management → Users → App Metadata, or the Management API):
{ "app_metadata": { "app_role": "app_viewer" } }This is the explicit admin action role assignment is meant to be. It matters doubly for federated logins (Google Workspace or another upstream IdP through an Auth0 connection): a first-time federated user is created with empty app_metadata, so they authenticate fine and are rejected by Kozou until an admin assigns the role. Also note the claim is baked in at token issue time — changing app_metadata does not alter tokens already issued; the user needs a fresh token (re-authorization or refresh) to pick it up.
If you bridge an upstream IdP, the division of labor is: the upstream directory authenticates (accounts, MFA, conditional access); Auth0 owns the authorization material (app_role). Nothing needs to change upstream.
Step 4: register the clients
Section titled “Step 4: register the clients”This is where Auth0 differs most from Keycloak. Auth0 supports dynamic client registration (one tenant flag), but every dynamically-registered third-party client must additionally be authorized for your API before its first authorization request succeeds — a per-client client grant with subject_type: "user", created via the Management API. Without it, /authorize fails with invalid_request (“Client … is not authorized to access resource server”), which end users see as Auth0’s generic “Oops!, something went wrong” page. This is an AS-side approval; nothing Kozou serves can bypass it.
That wall shapes the practical recipe per client:
- claude.ai — pre-register a client (required in practice). claude.ai registers and then authorizes almost immediately; when authorization fails on the missing grant, it discards that client and registers a new one on the next attempt, so granting after the fact never catches up. Instead: create a Regular Web Application in Auth0 with callback URL
https://claude.ai/api/mcp/auth_callback, authorize it for your API once, and paste its client ID and secret into the connector’s configuration in claude.ai (the connector settings accept an OAuth client ID / secret for exactly this situation). - ChatGPT — automate the grant, or pre-register here too. ChatGPT falls back to DCR and registers a new client on every connect attempt (each named “ChatGPT”). If you keep DCR open, you need Management-API automation that detects and authorizes them; a pre-registered client avoids the treadmill.
- Claude Code — DCR works. Enable dynamic registration (tenant settings,
enable_dynamic_client_registration), and note two prerequisites for third-party clients: the connection your users sign in with must be promoted to domain level (is_domain_connection: true), and each registered client still needs the client grant above (a one-time grant per developer, since Claude Code caches its registration per server URL).
Also plan for client hygiene: dynamically-registered clients accumulate (they count against tenant application quotas), and deleting one that a client still has cached turns into an opaque end-user error — see operating notes.
Point Kozou at the tenant
Section titled “Point Kozou at the tenant”server: mcp: http: auth: resource: https://mcp.example.com/mcp authorizationServers: - https://your-tenant.auth0.com/ jwt: jwksUri: https://your-tenant.auth0.com/.well-known/jwks.json algorithms: [RS256] roleClaim: role allowedRoles: [app_viewer, app_editor] extraScopesSupported: [offline_access]Mind the trailing slash: Auth0’s issuer is https://your-tenant.auth0.com/ (with the slash), and Kozou uses the authorizationServers entries as the expected iss, matched exactly. Custom domains work the same way — use the custom domain as the issuer if your tenant is configured for one.
Verify it
Section titled “Verify it”curl https://mcp.example.com/.well-known/oauth-protected-resource— your tenant should appear inauthorization_servers.- Connect a real client (Claude Code:
claude mcp add --transport http kozou https://mcp.example.com/mcp, then/mcp→ authenticate). The Universal Login and consent screens should appear; consent lists your scope names. - Decode the access token:
aud= your resource URI (no mapper involved — that’s the RFC 8707 path working),iss= your tenant URL with trailing slash,role= the PostgreSQL role fromapp_metadata. - Negative checks: a user without
app_metadata.app_rolemust be rejected; an authorization attempt from an unapproved dynamically-registered client must fail at Auth0 (that’s the client-grant wall, not a Kozou error).
Where to next
Section titled “Where to next”- Remote MCP with OAuth — the mode itself, the strictness rules, and client-by-client behavior.
- Keycloak as the authorization server — the self-hosted alternative; no per-client approval wall, but the audience/role mappers move to you.
- Authentication and authorization — what Kozou enforces and what it delegates.