Skip to content

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.

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:describe and mcp: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 setCustomClaim calls for names like preferred_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_role is 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.

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.

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.

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.

  1. curl https://mcp.example.com/.well-known/oauth-protected-resource — your tenant should appear in authorization_servers.
  2. 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.
  3. 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 from app_metadata.
  4. Negative checks: a user without app_metadata.app_role must 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).