Febasidocs
Concepts

JWT secrets per tenant

How tenants own their JWT signing material — and how rotation works without invalidating in-flight tokens.

Every tenant can sign their JWTs with a dedicated secret stored encrypted at rest. The default is the service-wide JWT_SECRET, but a tenant can graduate to its own secret at any time, and rotate it safely without breaking active sessions.

Why per-tenant secrets

Two reasons:

  1. Blast radius. A compromise of tenant A's secret never affects tenant B.
  2. Cryptographic independence. A tenant that needs its own KMS or HSM workflow can plug it into the rotation endpoint and own the secret end-to-end.

How storage works

Tenant secrets are persisted in the tenants table:

FieldPurpose
jwt_secret_encryptedThe active secret, encrypted with AES-256-GCM.
jwt_secret_previous_encryptedThe previous secret, kept while it expires.
jwt_secret_previous_expires_atTimestamp after which the previous secret is dropped.

Both encrypted columns are encrypted with the service-wide ENCRYPTION_KEY. Febasi Auth uses the active secret for every signature it issues; the previous one stays around only to validate tokens already in flight.

Generating a new secret

POST /api/v1/tenants/me/jwt-secret/generate
Authorization: Bearer <jwt-with-tenants-update-permission>

This:

  1. Generates a fresh 64-byte random secret.
  2. Encrypts it and writes it as the new jwt_secret_encrypted.
  3. Moves the old secret into jwt_secret_previous_encrypted with an expiration timestamp 7 days in the future.
  4. Returns the generated secret plus rotation metadata.

From this moment on, new tokens are signed with the new secret, while old tokens continue to validate until the previous secret expires.

Why dual-secret validation matters

Without an overlap window, every active client would be force-logged-out the instant a secret rotates. With a 7-day overlap, you can rotate quarterly while clients refresh on their normal cadence and never see a glitch.

Setting a custom secret

If you bring your own secret material (KMS-issued, hardware-backed, etc.):

PUT /api/v1/tenants/me/jwt-secret
{
  "secret": "<your-32-to-512-character-secret>"
}

The same dual-secret semantics apply — your previous secret keeps validating tokens for 7 days.

Rotating an existing secret

POST /api/v1/tenants/me/jwt-secret/rotate

Rotates the current secret into the previous slot and generates a new active one. Requires a secret to already exist. Returns the new secret and the previous-expiration timestamp.

Inspecting status

GET /api/v1/tenants/me/jwt-secret
{
  "success": true,
  "data": {
    "hasSecret": true,
    "secret": "<decrypted-active-secret>",
    "rotatedAt": "2026-05-01T12:00:00Z",
    "hasPreviousSecret": true,
    "previousExpiresAt": "2026-05-08T12:00:00Z"
  }
}

The active secret is returned decrypted so the tenant can mirror it in its own applications for local token validation. Treat this endpoint as sensitive — gate it behind tenants:read and keep responses out of logs.

Falling back to the global secret

To drop the tenant's custom secret and revert to the service-wide JWT_SECRET:

DELETE /api/v1/tenants/me/jwt-secret

This nulls out both columns. Tokens signed with the previous tenant secret will fail validation immediately — only run this when you have already forced clients to re-login or you accept the disruption.

Verification path

When a token arrives, Febasi Auth:

  1. Resolves the tenant from the URL or the unverified token claims.
  2. Picks the active tenant secret (or the global one if not configured).
  3. Verifies the signature.
  4. If verification fails and the tenant has an unexpired jwt_secret_previous_*, retries with the previous secret.
  5. If both fail, the token is rejected as INVALID_TOKEN.

This dual-secret check is what keeps rotation from being a flag day.

On this page