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
issuerURL (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:
- NEXT — generated and published in JWKS, but not yet signing anything.
- ACTIVE — the key currently signing new tokens. Exactly one per tenant.
- 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.