Skip to content

JWT Configuration

Configure JSON Web Tokens (JWT) for API authentication in Mantis.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyLWlkIiwidGVuYW50IjoiYWJjMTIzIiwiZXhwIjoxNzA0MDY3MjAwfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
PartDescription
HeaderAlgorithm and token type
PayloadClaims (user, tenant, expiry)
SignatureCryptographic signature
AlgorithmTypeKeyUse Case
HS256SymmetricShared secretSimple deployments
RS256AsymmetricRSA key pairEnterprise, key separation

Uses a shared secret key:

┌─────────────────────────────────────────────────────────────┐
│ HS256 Key Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ Same secret key used for: │
│ • Signing tokens (Mandible) │
│ • Verifying tokens (Mandible) │
│ │
│ Pros: Simple, fast │
│ Cons: Secret must be shared with all verifiers │
│ │
└─────────────────────────────────────────────────────────────┘

Uses public/private key pair:

┌─────────────────────────────────────────────────────────────┐
│ RS256 Key Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ Private key: Signs tokens (keep secret) │
│ Public key: Verifies tokens (can be shared) │
│ │
│ Pros: Key separation, public verification │
│ Cons: Slower, larger tokens │
│ │
└─────────────────────────────────────────────────────────────┘
[jwt]
algorithm = "HS256"
secret = "your-256-bit-secret-key-here-at-least-32-chars"
access_token_expiry_secs = 3600 # 1 hour
refresh_token_expiry_secs = 2592000 # 30 days
issuer = "https://mantis.example.com"

Generate a secure secret:

Terminal window
# Generate 256-bit secret
openssl rand -base64 32
# Or using /dev/urandom
head -c 32 /dev/urandom | base64
[jwt]
algorithm = "RS256"
private_key_path = "/etc/mantis/jwt/private.pem"
public_key_path = "/etc/mantis/jwt/public.pem"
access_token_expiry_secs = 3600 # 1 hour
refresh_token_expiry_secs = 2592000 # 30 days
issuer = "https://mantis.example.com"

Generate RSA key pair:

Terminal window
# Generate private key
openssl genrsa -out /etc/mantis/jwt/private.pem 2048
# Extract public key
openssl rsa -in /etc/mantis/jwt/private.pem \
-pubout -out /etc/mantis/jwt/public.pem
# Secure private key
chmod 600 /etc/mantis/jwt/private.pem
VariableDescriptionExample
MANDIBLE__JWT__ALGORITHMAlgorithmHS256 or RS256
MANDIBLE__JWT__SECRETHS256 signing secret, at least 32 characters$(openssl rand -hex 32)
MANDIBLE__JWT__PRIVATE_KEY_PATHRS256 private key/etc/mantis/jwt/private.pem
MANDIBLE__JWT__PUBLIC_KEY_PATHRS256 public key/etc/mantis/jwt/public.pem
MANDIBLE__JWT__ACCESS_TOKEN_EXPIRY_SECSToken lifetime (seconds)3600
MANDIBLE__JWT__REFRESH_TOKEN_EXPIRY_SECSRefresh lifetime (secs)604800
MANDIBLE__JWT__ISSUERToken issuerhttps://api.example.com
MANDIBLE__JWT__AUDIENCEToken audiencelens
MANDIBLE__JWT__KEY_IDCurrent signing key IDk1

The credential lookup key is not a JWT setting

Section titled “The credential lookup key is not a JWT setting”

MANDIBLE__SECURITY__TOKEN_LOOKUP_KEY is required under both RS256 and HS256. It is listed here only because it used to be MANDIBLE__JWT__SECRET, which is why it is easy to assume RS256 does not need it.

An API key and an OAuth2 token are stored as an Argon2 hash, which cannot be searched. To find the row for a presented token, Mantis also stores an HMAC of that token under this key and looks it up by that value. Keeping the key secret is what stops anyone who obtains a copy of the database from computing those values for themselves.

Terminal window
MANDIBLE__SECURITY__TOKEN_LOOKUP_KEY="$(openssl rand -base64 32)"

Short-lived token for API access:

SettingDefaultRecommended Range
access_token_expiry_secs3600900-86400 seconds

Longer-lived token to obtain new access tokens:

SettingDefaultRecommended Range
refresh_token_expiry_secs2592000 (30 days)86400-2592000 seconds
ClaimDescriptionExample
subSubject (user ID, a UUID)019b937d-4862-8e07-ac3a-1b8589d1b807
issIssuerhttps://mantis.example.com
audAudiencelens
expExpiration time1704067200
iatIssued at1703980800
nbfNot before1703980800
jtiToken ID019b9450-0b91-8c10-86c3-5c2c3f6b9f04
ClaimDescriptionExample
emailUser emailuser@example.com
usernameUsernamejsmith
tenant_idTenant the request acts in (UUID, or null)019b937d-4862-8387-9a86-df16bd76ca99
tenant_membershipsEvery tenant the account belongs to; omitted for a platform-scoped account["019b937d-4862-8387-9a86-df16bd76ca99"]
rolesUser roles (array)["operator"]
api_key_scopesScopes when authenticated via API key; gates the API-key scope check["deployments:read"]
pendingThe step a sign-in still owes: second_factor or password_change"second_factor"
session_idSession identifier (UUID)019b9450-1234-8000-9abc-def012345678
credential_epochHow many account-wide revocations the account had been through at sign-in0

api_key_scopes, pending, session_id and credential_epoch are omitted when they do not apply.

sub, tenant_id and session_id must parse as UUIDs — decoding rejects any other format.

Example payload:

{
"sub": "019b937d-4862-8e07-ac3a-1b8589d1b807",
"iss": "https://mantis.example.com",
"aud": "lens",
"exp": 1704067200,
"iat": 1703980800,
"nbf": 1703980800,
"jti": "019b9450-0b91-8c10-86c3-5c2c3f6b9f04",
"email": "user@example.com",
"username": "jsmith",
"tenant_id": "019b937d-4862-8387-9a86-df16bd76ca99",
"tenant_memberships": ["019b937d-4862-8387-9a86-df16bd76ca99"],
"roles": ["operator"],
"session_id": "019b9450-1234-8000-9abc-def012345678",
"credential_epoch": 0
}
AspectJWTAPI Key
LifetimeShort (hours)Long (months/years)
RevocationOn expiryManual
Use caseUser sessionsService accounts
Contains claimsYesNo (server lookup)

API keys are always Argon2-hashed before storage and identified by the mnt_ prefix. There is no [api_keys] configuration section — hashing is built in and not configurable.

API keys are created through the REST API (or the Lens UI), not a CLI subcommand:

Terminal window
curl -X POST "https://api.mantis.example.com/api/v1/auth/api-keys" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "ci-pipeline", "description": "CI/CD deployment key"}'
# Response includes the full key exactly once in the "full_key" field:
# {"full_key": "mnt_7K4vBz9mQxNwR5pLy2HjC8dTfG6sAeWuJn3iX1kM4o", "api_key": { ... }, "warning": "..."}
# Store it securely - it cannot be retrieved later.

API keys use the Authorization: Bearer header with mnt_ prefix:

Terminal window
# API key authentication
curl -H "Authorization: Bearer mnt_7K4vBz9mQxNwR5pLy2HjC8dTfG6sAeWuJn3iX1kM4o" \
https://mantis.example.com/api/v1/targets

HS256 secrets:

Terminal window
# Generate strong secret (at least 256 bits)
openssl rand -base64 32
# Store in environment variable (not in config file)
export MANDIBLE__JWT__SECRET="$(cat /etc/mantis/secrets/jwt-secret)"

RS256 keys:

Terminal window
# Secure key file permissions
chmod 600 /etc/mantis/jwt/private.pem
chmod 644 /etc/mantis/jwt/public.pem
chown mantis:mantis /etc/mantis/jwt/*.pem
PracticeImplementation
Short expiry1-24 hours for access tokens
Secure transmissionHTTPS only
HttpOnly cookiesPrevent XSS access
SameSite cookiesPrevent CSRF
[cookie]
secure = true # HTTPS only
same_site = "Lax" # CSRF protection
domain = ".example.com" # Cookie domain
path = "/" # Cookie path

Mandible performs these checks:

  1. Signature verification - Token not tampered
  2. Expiration check - Token not expired
  3. Issuer check - Correct issuer claim
  4. Token ID check - Not revoked (if tracking enabled)

For immediate token invalidation, revoke the underlying sessions through the REST API. Logging out (POST /api/v1/auth/logout) revokes the session it is called from and no other: a signed-out device stops appearing in the session list, and the user’s other devices stay signed in. Use DELETE /api/v1/auth/sessions to revoke the user’s other sessions.

Revoking a session takes effect at once, in both directions: the refresh token issued for that session is bound to it and is removed with it, so the browser cannot mint a replacement, and the session is added to a Redis blocklist that denies the access token it already holds on that token’s next request. The blocklist entry is kept for access_token_expiry_secs, after which no token from that session can still be valid. Password changes, password resets (self-service and admin-initiated) and email changes end the user’s sessions the same way. Every interactive sign-in has a session, including SSO — so an SSO login is listed and revocable like any other.

They also end whatever was on its way to becoming a session. A sign-in that had passed the password step and was waiting on its second factor, and an OAuth authorization code issued but not yet redeemed, are both refused afterwards: the user signs in again.

Open event streams (/api/v1/sse/…) are covered too. A stream authorizes once, when it is opened, and then runs for as long as the client holds it — longer than the token it presented — so it rechecks while it serves and closes once its session is revoked, instead of feeding a revoked session until it disconnects.

Terminal window
# Revoke a specific session by ID
curl -X DELETE "https://api.mantis.example.com/api/v1/auth/sessions/$SESSION_ID" \
-H "Authorization: Bearer $TOKEN"
# Revoke all of the current user's OTHER sessions (the current session is kept active)
curl -X DELETE "https://api.mantis.example.com/api/v1/auth/sessions" \
-H "Authorization: Bearer $TOKEN"

Redis availability dependency (fail-closed)

Section titled “Redis availability dependency (fail-closed)”

The JWT revocation (blocklist) check is backed by Redis, and it fail-closes: if the blocklist Redis backend is unreachable, Mandible treats the token as denied with 503 Service Unavailable (not 401 — an auth-backend outage reported as a token problem would trigger a refresh-token storm). This is a deliberate choice — a Redis outage must not let a revoked token through — but it has an important availability consequence:

Mandible exports these Prometheus metrics for the dependency:

MetricMeaning
mantis_jwt_blocklist_redis_available1 if the JWT blocklist Redis is reachable, 0 if not (set on the request path).
mantis_jwt_blocklist_redis_errors_totalCumulative blocklist Redis errors, labelled by operation.
mantis_sse_redis_upBackground-probed (~10s) SSE/session Redis reachability, independent of traffic.

The bundled alert rules (deploy/ansible/roles/monitoring/files/mandible-alerts.yaml) page on mantis_jwt_blocklist_redis_available == 0 (critical) and warn on SSE Redis being down. When this fires, follow the Redis availability runbook: restore Redis, or make a conscious decision (e.g. reduce JWT TTL) while it is recovered.

Support multiple keys during rotation:

[jwt]
algorithm = "HS256"
# Current key (used for signing). At least 32 characters.
secret = "9f4c1b7e2a8d6035f1c9b4e7a2d8065c3f1b9e4a7d206c8f"
# Previous key (verification only). It verifies for the whole overlap window, so
# it is held to the same minimum length.
previous_secret = "2d8065c3f1b9e4a7d206c8f9f4c1b7e2a8d6035f1c9b4e7a"
key_id = "k2"
previous_key_id = "k1"

Rotation process:

  1. Add new secret as primary, move old secret to previous_secret
  2. Assign a new key_id and set previous_key_id to the old key ID
  3. Wait for old tokens to expire
  4. Remove previous_secret and previous_key_id
[jwt]
algorithm = "RS256"
private_key_path = "/etc/mantis/jwt/private-new.pem"
public_key_path = "/etc/mantis/jwt/public-new.pem"
key_id = "k2"
previous_public_key_path = "/etc/mantis/jwt/public-old.pem"
previous_key_id = "k1"

See Key Rotation for detailed procedures.

The [jwt] table configures only the signing keys for Mantis’s own tokens. Accepting tokens from external identity providers (OIDC/JWKS) is not configured under [jwt] — there are no [jwt.external], [jwt.validation], jwks_uri, or jwks_refresh_interval keys. External identity providers are configured at runtime through the admin identity-providers API and stored in the database, where each provider carries its own issuer/audience/JWKS settings.

See Identity Providers and OIDC Configuration for the full setup.

ErrorCauseSolution
invalid signatureWrong secret/keyVerify key configuration
token expiredPast expirationRequest new token
invalid issuerWrong issuer claimCheck issuer configuration
invalid audienceWrong audience claimCheck audience configuration

Decode JWT (without verification):

Terminal window
# Decode header
echo "$JWT" | cut -d. -f1 | base64 -d 2>/dev/null | jq
# Decode payload
echo "$JWT" | cut -d. -f2 | base64 -d 2>/dev/null | jq

Online tools (development only):

  • jwt.io - Decode and verify JWTs
  • jwt.ms - Microsoft JWT decoder

Token immediately expires:

Terminal window
# Check server time synchronization
timedatectl status
# Sync time if needed
timedatectl set-ntp true

Signature verification fails:

Terminal window
# Verify secret is correct
echo -n "test" | openssl dgst -sha256 -hmac "$SECRET"
# For RS256, verify key pair matches
openssl rsa -in private.pem -pubout | diff - public.pem

Token not accepted after key rotation:

Terminal window
# Ensure previous keys are configured
grep previous_secret /etc/mantis/mandible.toml
# Check token was signed with tracked key
[jwt]
algorithm = "RS256" # Prefer asymmetric
private_key_path = "/etc/mantis/jwt/private.pem"
public_key_path = "/etc/mantis/jwt/public.pem"
access_token_expiry_secs = 28800 # 8 hours
refresh_token_expiry_secs = 604800 # 7 days
issuer = "https://mantis.example.com"
[cookie]
secure = true
same_site = "Lax"

Tighten security with short token lifetimes, RS256, and an explicit audience. These are the only JWT knobs the server exposes — there is no require_token_binding field or [jwt.validation] section:

[jwt]
algorithm = "RS256"
private_key_path = "/etc/mantis/jwt/private.pem"
public_key_path = "/etc/mantis/jwt/public.pem"
access_token_expiry_secs = 3600 # 1 hour
refresh_token_expiry_secs = 86400 # Daily re-auth
issuer = "https://mantis.example.com"
audience = "lens"