cwvermaak.dev
· 3 min read

Per-tenant signing keys in a multi-tenant OIDC issuer

One binary, many tenants, and a different signing key for each. How WeldForge keeps token issuance isolated without running an issuer per customer.

A single-tenant OIDC provider has one signing key. It publishes the public half at /.well-known/jwks.json, signs every ID token and access token with the private half, and every relying party verifies against that one key. Simple.

Multi-tenancy breaks that comfortable assumption. If tenant A and tenant B share a signing key, then a token minted for A is cryptographically indistinguishable from one minted for B. That's not an isolation boundary — it's a shared secret with extra steps.

The requirement

In WeldForge, every tenant gets its own issuer identity:

  • a distinct issuer URL (https://id.example.com/t/{tenant}),
  • a distinct JWKS endpoint,
  • and its own signing key, rotated independently.

A token from one tenant must fail verification against any other tenant's keys. Not by policy — by mathematics.

The shape of the solution

Each tenant has a small set of keys in the database, tenant-scoped like every other row:

CREATE TABLE signing_key (
    id           UUID PRIMARY KEY,
    tenant_id    UUID NOT NULL REFERENCES tenant(id),
    kid          TEXT NOT NULL,           -- key id, surfaced in the JWT header
    algorithm    TEXT NOT NULL,           -- e.g. RS256, ES256
    private_pem  TEXT NOT NULL,           -- encrypted at rest
    public_pem   TEXT NOT NULL,
    status       TEXT NOT NULL,           -- ACTIVE | NEXT | RETIRED
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

The kid is the hinge. It travels in the JWT header, so verification can select the right public key without guessing:

{ "alg": "RS256", "kid": "t_acme_2026_05", "typ": "JWT" }

When a request comes in for tenant acme, the token service loads acme's ACTIVE key, signs with it, and stamps the kid. The JWKS endpoint for acme publishes only acme's public keys.

Rotation without downtime

The trick to rotating keys without invalidating live tokens is to publish the new public key before you sign with it. WeldForge models that with three states:

  1. NEXT — generated and published in JWKS, but not yet signing anything.
  2. ACTIVE — the key currently signing new tokens. Exactly one per tenant.
  3. RETIRED — no longer signing, but kept in JWKS until every token it signed has expired.

Promotion walks NEXT → ACTIVE → RETIRED. Because relying parties refresh JWKS and key-select by kid, a token signed by the outgoing key keeps verifying right up to its expiry. No flag day, no coordinated restart.

The part that bites you

The failure mode isn't the cryptography — it's a query that forgets its tenant. One unscoped SELECT * FROM signing_key WHERE status = 'ACTIVE' and you're suddenly signing tenant A's tokens with tenant B's key.

The defence is to make tenant-scoping the default, not a thing you remember to add. Every repository method takes a tenant context; the one place that can issue a query without it is small, audited, and loud. The audit webhook fires on every key promotion, HMAC-signed, so there's a tamper-evident trail of which key was active when.

Multi-tenant isolation is a property you maintain on every single query, or it's a property you don't have.

That's the whole game: not the clever bit, the boring bit, done every time.