Skip to content

Authenticate callers

Every tool requires a verified identity. This guide is how callers get one.

There are two kinds of caller, and a credential type for each:

  • Scripts, CI, machine-to-machine present an sk_… API key.
  • Interactive clients (Claude Desktop, your own agents) present an OAuth 2.1 JWT from your provider.

Both resolve to the same CallerIdentity and hit the same scope check. Pick whichever fits the caller, or use both.

API keys

An API key is an sk_… bearer token. The caller sends it as Authorization: Bearer sk_….

Pontifex doesn't mint keys. It reads and enforces them. An upstream system you control (an admin tool, a CLI, a config file) provisions the key with a set of scopes, and Pontifex validates each call against them.

Provision a key

A key record carries an identity, the granted scopes, and a rate limit. The plaintext is shown once; only its hash is stored.

import hashlib, secrets

raw_key = "sk_live_" + secrets.token_urlsafe(32)
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()

# Write to your store with the scopes this caller may use:
#   owner_label   = "Kwame's Claude Desktop"
#   key_hash      = key_hash
#   scopes        = ["orders:order:read"]
#   rate_limit_rpm = 120

print(f"Give this to the caller (shown once): {raw_key}")

Key environments

The sk_<env>_ prefix marks the environment: sk_live_ in production, sk_test_ / sk_uat_ for CI and ephemeral envs. They all share the sk_ discriminator the middleware routes on, so a test key can never be mistaken for a JWT.

Scope it to the minimum

Scopes are namespace:resource:action. Grant the least that does the job.

Give the caller If they should…
orders:order:read call exactly one tool
orders:*:read read anything in the orders namespace
orders:*:* do anything in the orders namespace

Full rules and wildcard behavior: Errors & scopes.

OAuth 2.1 (interactive clients)

For clients where a human logs in, use OAuth. Pontifex is a pure resource server: it validates the JWT your provider issues and maps its claims to a CallerIdentity. It never runs a login UI and never mints tokens.

New to OAuth?

This section is the reference. For a click-by-click walkthrough that covers creating the API, defining permissions, and filling in the variables (with a copy-paste prompt for a coding agent), follow Set up OAuth, step by step.

Point it at your provider

Set the AUTH_* environment variables. That's what turns the JWT path on.

AUTH_JWKS_URL=https://your-provider.example/.well-known/jwks.json
AUTH_ISSUER=https://your-provider.example/
AUTH_AUDIENCE=<the resource-server identifier your token's `aud` carries>
AUTH_SCOPES_CLAIM=permissions     # Auth0; Entra: scp or roles
AUTH_AUTHORIZATION_SERVER=https://your-provider.example/
PUBLIC_BASE_URL=https://your-deployment.example

Any OIDC provider works: Auth0, Microsoft Entra, Clerk, Keycloak. To switch providers, edit the config; the code stays the same.

Clients bootstrap themselves

A client holding no credentials gets a 401 with a WWW-Authenticate challenge pointing at /.well-known/oauth-protected-resource (RFC 9728). Spec-compliant MCP clients read that, find your authorization server, and run the login flow on their own. No out-of-band setup.

Map roles to scopes

Your provider's roles and permissions need to land in the configured scopes claim as namespace:resource:action strings. If your provider supports a post-login hook, use it to grant new users a read-only role.

Why both paths are safe

Whichever credential a caller presents, the same rules apply downstream:

  • Scopes and rate limits come from server configuration and verified claims, never from anything the caller can set.
  • JWT validation is asymmetric-only and rejects alg: none.
  • A rejected credential returns one generic message, with no hint about why it failed.

The full model is in Security.