Skip to content

Use multiple issuers

One server is many identity providers. Every namespace under a single path segment — http://localhost:8080/{issuer}/... — is an independent issuer with its own signing key and its own discovery document. There is no registration step: an issuer exists the moment you touch it. Use this to test tenant isolation, multi-IdP federation, or a token-audience matrix from a single container.

All examples below use base http://localhost:8080 and two issuers, acme and beta.

Materialize an issuer by touching it

Hit any issuer-scoped endpoint with a name you choose. The issuer springs into existence with a lazily generated signing key:

curl -s http://localhost:8080/acme/.well-known/openid-configuration
#   => "issuer":"http://localhost:8080/acme",
#      "authorization_endpoint":"http://localhost:8080/acme/authorize",
#      "token_endpoint":"http://localhost:8080/acme/token", ...  (all under /acme/)

Do the same for beta — no config, no restart:

curl -s http://localhost:8080/beta/.well-known/openid-configuration
#   => "issuer":"http://localhost:8080/beta", ...  (all under /beta/)

Each issuer's signing key uses kid == <issuer id>. Fetch the two JWKS and the key sets are distinct:

curl -s http://localhost:8080/acme/jwks
#   => {"keys":[{"kty":"RSA","kid":"acme","use":"sig","alg":"RS256", ...}]}

curl -s http://localhost:8080/beta/jwks
#   => {"keys":[{"kty":"RSA","kid":"beta","use":"sig","alg":"RS256", ...}]}

Note

JWKS exposes public key members only (no d, p, q, ...). The kid always equals the issuer id, so a verifier can key its trust off the issuer name alone.

Prove keys are isolated

Each issuer is a self-contained trust domain: a token signed by one is worthless to another. Mint an access token under acme (see the control plane reference for the full /_mock/mint body):

ACME_TOKEN=$(curl -s http://localhost:8080/_mock/mint \
  -H 'content-type: application/json' \
  -d '{"issuer":"acme","subject":"alice","audience":["acme-api"],"clientId":"web","kind":"access_token"}' \
  | jq -r .token)

It verifies at acme's userinfo:

curl -s http://localhost:8080/acme/userinfo -H "Authorization: Bearer $ACME_TOKEN"
#   => 200  {"sub":"alice","aud":["acme-api"], ...}

The same token is rejected by beta, whose JWKS advertises only kid=beta:

curl -si http://localhost:8080/beta/userinfo -H "Authorization: Bearer $ACME_TOKEN"
#   => HTTP/1.1 401 Unauthorized
#      WWW-Authenticate: Bearer error="invalid_token"
#      {"error":"invalid_token"}

A minted token is byte-identical to a granted one, so the same isolation holds for tokens obtained through /{issuer}/token or the authorization-code flow: a token minted or granted under one issuer will not pass userinfo or introspect under another.

Pre-seed issuer-specific behavior

To give each issuer its own default claims, audience, or typ before any traffic arrives, use config tokenCallbacks. Each entry is keyed by issuer and applies to grants for that issuer only; the first matching entry wins.

{
  "tokenCallbacks": [
    {
      "issuer": "acme",
      "audience": ["acme-api"],
      "claims": { "tenant": "acme", "roles": ["admin"] }
    },
    {
      "issuer": "beta",
      "audience": ["beta-api"],
      "claims": { "tenant": "beta" }
    }
  ]
}

Start the server with that file (for example JSON_CONFIG_PATH=./config.json; see the configuration reference for the full shape and precedence), then a plain grant against each issuer carries its own seeded claims:

ACME_AT=$(curl -s http://localhost:8080/acme/token \
  -d grant_type=client_credentials -d client_id=web | jq -r .access_token)

curl -s http://localhost:8080/acme/userinfo -H "Authorization: Bearer $ACME_AT"
#   => {"sub":"web","aud":["acme-api"],"tenant":"acme","roles":["admin"], ...}

A beta grant instead reports "tenant":"beta" and aud:["beta-api"].

Tip

A tokenCallbacks entry is the same shape as a /_mock/scenarios body, so you can seed a durable default in config and still enqueue a one-shot override at runtime for the same issuer. Issuers that are not named in the config still work — they materialize on first touch with the built-in default callback.

Constraints you will hit

Issuer ids are a single path segment. No / is allowed. A request to /tenants/acme/authorize treats tenants as the issuer and acme/authorize as a path beneath it — it does not create a nested tenants/acme issuer. Nested, multi-segment (Azure-style) issuers are a deliberate, documented parity gap; see Issuers and advertised identity for why and what to do instead.

_mock is reserved. It is the control plane, so you cannot use it as an issuer id — issuer-scoped routes under that prefix return 404 with the OAuth2 error not_found, and /_mock/mint rejects a reserved-prefix issuer:

curl -si http://localhost:8080/_mock/.well-known/openid-configuration
#   => HTTP/1.1 404 Not Found
#      {"error":"not_found", ...}

FOR TESTING ONLY

Materialize-on-touch means any string becomes a trusted issuer with a valid signing key. That is exactly what makes this useful for tests and exactly why it must never front production traffic.