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 using the service-wide ENCRYPTION_KEY. Plaintext secrets are never returned by any endpoint. The only way to use them is to issue/verify tokens through Febasi Auth itself.

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 (configurable).
  4. Returns metadata about the rotation (creation time, expiration of the previous secret).

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.):

POST /api/v1/tenants/me/jwt-secret/set
{
  "secret": "<your-64+-byte-secret>",
  "previousSecretExpiresInDays": 7
}

The same dual-secret semantics apply — your previous secret keeps validating tokens until the configured grace period elapses.

Inspecting status

GET /api/v1/tenants/me/jwt-secret
{
  "success": true,
  "data": {
    "hasCustomSecret": true,
    "hasPreviousSecret": true,
    "previousSecretExpiresAt": "2026-05-08T12:00:00Z",
    "createdAt": "2026-05-01T12:00:00Z"
  }
}

The response never contains the secret material itself.

Falling back to the global secret

To revert to the service-wide JWT_SECRET:

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

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