Skip to main content

JWT signing and JWKS

identity-service issues JWT access and refresh tokens for Shellui sessions and personal access tokens (PATs). External services can verify those tokens without sharing SECRET_KEY by fetching the public keys from the JWKS endpoint.

Endpoint​

ItemValue
URLGET /.well-known/jwks.json
AuthNone (public)
CacheCache-Control: public, max-age=900 (15 minutes)

Example:

curl -s https://auth.example.com/.well-known/jwks.json | jq .

Response shape (RS256):

{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "abc123...",
"n": "...",
"e": "AQAB"
}
]
}

Tokens include a kid header matching the active signing key. Verifiers should select the JWK with the same kid, or try each key when kid is absent.

Configuration​

Production (required)​

Set an RSA private key. The service refuses to start in production (DEBUG=false) without it.

# Generate a key pair and suggested env vars
uv run python manage.py generate_jwt_keys
VariableRequiredDescription
JWT_PRIVATE_KEYYes (production)PEM-encoded RSA private key. Use \n for newlines in .env. Do not include the surrounding quotes from generate_jwt_keys when pasting into Coolify / Docker UI.
JWT_PUBLIC_KEYNoPEM public key. Derived from the private key when omitted.
JWT_KEY_IDNoKey id (kid). Defaults to RFC 7638 JWK thumbprint.
JWT_PREVIOUS_PUBLIC_KEYNoPrevious public key during rotation (still published in JWKS).
JWT_PREVIOUS_KEY_IDNokid for the previous key (auto-derived if omitted).
JWT_ACCEPT_HS256_LEGACYNoDefault false in production when RS256 is configured. Set true only during HS256→RS256 migration while old tokens may still be valid.
JWT_ISSUERYes (production)Issuer claim (iss) on issued tokens. Example: https://auth.example.com. Required when DEBUG=false.
JWT_AUDIENCEYes (production)Audience claim (aud) on issued tokens. Example: shellui. Required when DEBUG=false.

SECRET_KEY remains required for Django sessions and CSRF. It is not used for JWT signing when JWT_PRIVATE_KEY is set.

Local development​

When DEBUG=true and JWT_PRIVATE_KEY is unset, JWTs continue to use HS256 with SECRET_KEY (backward compatible). The JWKS endpoint returns {"keys":[]}.

For local RS256 testing, set JWT_PRIVATE_KEY the same way as production.

Coolify / Docker UI​

generate_jwt_keys prints a quoted .env line. Paste only the PEM value into Coolify (or any secret UI), not JWT_PRIVATE_KEY="...":

-----BEGIN PRIVATE KEY-----
MIIE...
-----END PRIVATE KEY-----

or the same text with literal \n instead of real newlines, without wrapping " / \". Including those quotes makes cryptography fail with InvalidByte(0, 92) (leading backslash).

Verifying tokens (consumer services)​

  1. Fetch /.well-known/jwks.json and cache it (respect Cache-Control, or refresh every 15 minutes).
  2. Parse the JWT header; read alg (expect RS256) and kid.
  3. Find the matching JWK by kid, or try each RSA key.
  4. Verify signature, exp, and any claims your service requires (company_id, email, etc.).

Libraries: PyJWT with PyJWKClient, jose, or your language’s JWT/JWKS stack.

Example with PyJWT:

import jwt
from jwt import PyJWKClient

JWKS_URL = "https://auth.example.com/.well-known/jwks.json"
jwks_client = PyJWKClient(JWKS_URL)

token = "..." # Bearer token
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience="shellui",
issuer="https://auth.example.com",
)

Key rotation​

  1. Generate a new key pair (uv run python manage.py generate_jwt_keys).
  2. Move the current public key to JWT_PREVIOUS_PUBLIC_KEY (and JWT_PREVIOUS_KEY_ID if you set a custom kid).
  3. Set the new private key as JWT_PRIVATE_KEY and update JWT_KEY_ID if needed.
  4. Deploy. New tokens use the new key; JWKS lists both keys.
  5. Wait until all outstanding tokens expire (up to 90 days for PATs, 7 days for refresh tokens).
  6. Remove JWT_PREVIOUS_* and set JWT_ACCEPT_HS256_LEGACY=false if migrating from HS256.

Security notes​

TopicGuidance
Private key storageUse a secret manager or mounted file. Never commit JWT_PRIVATE_KEY.
Key sizeMinimum 2048-bit RSA; generate_jwt_keys defaults to 3072 bits.
JWKS exposureOnly public keys are published. This is expected and safe.
HS256 legacyDefaults off in production after RS256 is configured. Keep JWT_ACCEPT_HS256_LEGACY=true only during migration.
iss / audRequired in production (JWT_ISSUER, JWT_AUDIENCE). Verifiers should validate both.
SECRET_KEYStill required; do not share it with token verifiers when using RS256.