JWT Plugin
Issue Ed25519 JWTs with issuer and audience binding, publish JWKS signing keys, and rotate single-use refresh tokens with replay protection.
Overview
The JWT plugin issues signed JWTs for authenticated users and machine clients, publishes the matching public keys through a JWKS endpoint, and manages rotating refresh tokens.
Every token carries a configured issuer (iss) and audience (aud). Validation is fail-closed: a token that is missing either claim, or carries the wrong value, is rejected.
The plugin does not authenticate requests by itself. Authentication is handled by other plugins, such as session, bearer, OAuth2, email/password, or magic link. Once authentication succeeds, the JWT plugin mints tokens and can write them into the JSON response.
Core Entities
- Signing key — an Ed25519 key pair used to sign tokens and published through JWKS.
- Refresh token — a hashed, single-use token that rotates on every successful refresh.
Features
- EdDSA signing — Ed25519 keys generated and rotated by the plugin.
- JWKS endpoint — publishes non-expired public keys at
/.well-known/jwks.json. - Access and refresh tokens — 15-minute access tokens and 7-day refresh tokens by default.
- Issuer and audience binding — every token carries
issandaud; both are validated on every access and refresh request. - Atomic refresh rotation — the old refresh token is revoked and the new one is stored in one transaction.
- Refresh-token validation — signature,
iss,aud, andtoken_typeare checked before the stored record is read. - Replay protection — reusing a revoked refresh token never mints tokens; reuse after the grace period invalidates the session.
- Session expiry enforcement — expired sessions are rejected during access-token validation and refresh.
- Separate token IDs — access and refresh tokens receive different
jtivalues. - Machine tokens — access-only tokens carrying
client_id, plus optionalorganization_idand scopes. - Reserved claims — security claims cannot be overridden by extra claims.
- Optional blacklisting — session and token revocation through the Secondary Storage plugin.
- Optional JWKS cache — secondary-storage cache with database fallback.
- Security events — refresh-token reuse throttling and malicious reuse events.
- No-store responses — token responses are marked as non-cacheable.
Configuration
A present [plugins.jwt] section enables the plugin unless enabled = false is set explicitly.
Standalone Mode
[plugins.jwt]
enabled = true
issuer = "http://localhost:8080/api/auth"
audience = "authula"
key_rotation_interval = "720h"
key_rotation_grace_period = "1h"
expires_in = "15m"
refresh_expires_in = "168h"
jwks_cache_ttl = "24h"
refresh_grace_period = "10s"Durations use Go duration strings, for example "15m", "720h", or "10s".
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true when the section is present | Load or skip the plugin. |
issuer | string | base_url + base_path | Token issuer (iss). The plugin fails to start when this is empty. |
audience | string | "authula" | Expected token audience (aud). The plugin fails to start when this is empty. |
key_rotation_interval | duration | "720h" | Maximum age of the active signing key before rotation is checked. |
key_rotation_grace_period | duration | "1h" | How long old keys remain published in JWKS after rotation. |
expires_in | duration | "15m" | Access token lifetime. |
refresh_expires_in | duration | "168h" | Refresh token lifetime. |
jwks_cache_ttl | duration | "24h" | TTL of the JWKS entry in secondary storage, when configured. |
refresh_grace_period | duration | "10s" | Window in which replay of a revoked refresh token is throttled instead of invalidating the session. |
Library Mode
import (
"time"
jwtplugin "github.com/Authula/authula/plugins/jwt"
jwtplugintypes "github.com/Authula/authula/plugins/jwt/types"
)
jwtPlugin := jwtplugin.New(jwtplugintypes.JWTPluginConfig{
Enabled: true,
Issuer: "http://localhost:8080/api/auth",
Audience: "authula",
KeyRotationInterval: 720 * time.Hour,
KeyRotationGracePeriod: time.Hour,
ExpiresIn: 15 * time.Minute,
RefreshExpiresIn: 168 * time.Hour,
JWKSCacheTTL: 24 * time.Hour,
RefreshGracePeriod: 10 * time.Second,
})The current implementation has no signing-algorithm option. Tokens are always signed with EdDSA (Ed25519).
Issuer and Audience
iss identifies the Authula deployment that signed the token. When issuer is not set, it defaults to base_url + base_path.
aud identifies the resource server that should accept the token. When audience is not set, it defaults to authula.
The audience is not the client ID. A machine client is identified by the client_id claim (and by sub). Use audience to name the protected resource, not the caller.
Rules:
- Every user access token, user refresh token, and machine access token is minted with
issandaud. - Access-token validation requires
issto equal the configured issuer andaudto contain the configured audience. - Refresh-token validation requires the same
issandaud, plustoken_type=refresh_token. - A token with an external or unknown audience is rejected, even if its signature is valid.
- Changing
issueroraudienceinvalidates every previously issued token.
Set issuer explicitly before changing base_url. Otherwise the derived issuer changes and all existing tokens stop validating.
Hard Cutover
Issuer and audience enforcement is a hard cutover:
- Tokens minted before this change have no
issoraudand are rejected. - Access tokens and refresh tokens issued before the upgrade stop working immediately after deploy.
- Users must sign in again. Machine clients must request new tokens.
- There is no legacy-token flag and no grace period.
Deploy during a maintenance window and tell integrators to re-mint their tokens.
API Reference
Routes are mounted under the configured base_path. The examples below use this table's route paths relative to that base path.
| HTTP Method | Route Path | Description |
|---|---|---|
POST | /token/refresh | Refresh token pair |
GET | /.well-known/jwks.json | Get public JWKS |
POST /token/refresh
Request:
{
"refresh_token": "eyJhbGciOiJFZERTQSJ9..."
}Successful response (200 OK):
{
"access_token": "eyJhbGciOiJFZERTQSJ9...",
"refresh_token": "eyJhbGciOiJFZERTQSJ9...",
"token_type": "Bearer",
"expires_in": 900
}expires_in is the access-token lifetime in seconds.
Response headers:
Cache-Control: no-store
Pragma: no-cacheError responses:
| Status | Condition | Body |
|---|---|---|
422 Unprocessable Entity | Invalid JSON or missing refresh_token. | {"message": "..."} |
401 Unauthorized | The token has an invalid signature, issuer, audience, or token_type. | {"message": "invalid refresh token"} |
401 Unauthorized | The token is unknown, revoked, or expired, belongs to a missing session, or the session is expired. | {"message": "invalid refresh token"} |
401 Unauthorized | The session_id or user_id claim does not match the stored refresh-token record or session. | {"message": "invalid refresh token"} |
401 Unauthorized | A revoked token was replayed. | {"message": "invalid refresh token"} |
GET /.well-known/jwks.json
Returns the current non-expired public keys:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"kid": "9f8b...",
"x": "...",
"alg": "EdDSA"
}
]
}Response header:
Cache-Control: public, max-age=3600Keys whose expires_at is in the past are excluded from this response.
Database Schema
Table: jwks
| Field | Type | Key | Description |
|---|---|---|---|
id | UUID | PK | Key ID, also used as the JWKS kid. |
public_key | string | - | PEM-encoded Ed25519 public key. |
private_key | string | - | Encrypted PEM-encoded Ed25519 private key. |
created_at | timestamp | - | Key creation time. |
expires_at | timestamp? | - | When the key stops being published. NULL for the active key. |
Table: refresh_tokens
| Field | Type | Key | Description |
|---|---|---|---|
id | UUID | PK | Refresh token record ID. |
session_id | UUID | FK | Reference to the session. |
token_hash | string | - | SHA-256 hash of the refresh token. The raw token is never stored. |
expires_at | timestamp | - | Refresh token expiration time. |
is_revoked | boolean | - | Whether the token has been rotated or revoked. |
revoked_at | timestamp? | - | When the token was revoked. |
last_reuse_attempt | timestamp? | - | Retained for compatibility; the current rotation logic does not write it. |
created_at | timestamp | - | Record creation time. |
In PostgreSQL and MySQL, session_id references sessions(id) with ON DELETE CASCADE. SQLite does not enforce the foreign key.
Refresh tokens are hashed at rest. JWT private keys are encrypted at rest.
Migrations are automatically handled when the plugin is initialized.
Database Hooks
The JWT plugin does not expose database hooks.
Plugin Capabilities
The JWT plugin provides the following capabilities and lifecycle behavior:
- Token issuance — mints tokens automatically after a successful authentication when the request context contains an actor and, for users, a session.
jwt.respond_json— writes the minted tokens into the JSON response. This capability must be listed in the route mapping for routes that should return tokens.- Issuer and audience enforcement — every minted token is bound to
issandaud; every validated token must match them. - Refresh-token rotation — atomically revokes the old refresh token and stores a new one.
- Replay protection — rejects revoked refresh tokens and invalidates the session on reuse after the grace period.
- Signing-key rotation — rotates Ed25519 keys when the active key is older than
key_rotation_interval. - Security events — emits refresh-token reuse events when an event bus is configured.
Token Model
Access Token Claims
| Claim | Description |
|---|---|
iss | Issuer. Defaults to base_url + base_path. |
aud | Audience. Defaults to authula. |
sub | User ID. |
user_id | User ID. |
session_id | Session the token belongs to. |
token_type | access_token. |
actor_type | user. |
jti | Unique token ID, separate from the refresh token's jti. |
iat | Issued-at time. |
exp | Expiration time. |
Extra actor claims are copied into the token, except for reserved claims. Reserved claims are iss, aud, sub, exp, iat, nbf, jti, token_type, actor_type, user_id, session_id, and client_id. Reserved claims cannot be overridden by extra claims.
organization_id is the canonical organization/tenant identifier. Custom claims such as organization_id and scopes may be supplied as extras when present on the actor.
Refresh Token Claims
| Claim | Description |
|---|---|
iss | Issuer. Defaults to base_url + base_path. |
aud | Audience. Defaults to authula. |
sub | User ID. |
user_id | User ID. |
session_id | Session the token belongs to. |
token_type | refresh_token. |
actor_type | user. |
jti | Unique token ID, different from the access token's jti. |
iat | Issued-at time. |
exp | Expiration time. |
Non-reserved custom claims are carried forward when the token is rotated. Registered claims, including iss, aud, sub, user_id, session_id, iat, exp, nbf, jti, token_type, actor_type, organization_id, scopes, and client_id, are not copied. New tokens are minted with the current issuer and audience.
Machine Token Claims
| Claim | Description |
|---|---|
iss | Issuer. Defaults to base_url + base_path. |
aud | Audience. Defaults to authula. |
sub | Client ID. |
client_id | Machine client ID. Same value as sub. |
token_type | access_token. |
actor_type | machine. |
organization_id | Optional organization binding. |
scopes | Optional machine scopes. |
jti | Unique token ID. |
iat | Issued-at time. |
exp | Expiration time. |
Machine tokens never include a refresh token.
Access Token Validation
- Verify the signature against JWKS.
- Validate
exp. - Require
issand reject it when it does not equal the configured issuer. - Require
audand reject it when it does not contain the configured audience. - Require
token_type=access_token. - Require
actor_typeto beuserormachine. Missing or unknown values are rejected. - Reject a blacklisted
jti, when blacklisting is enabled. - For users: require
user_idandsession_id, reject a blacklisted session, and reject a missing or expired session. - For machines: require
sub; returnorganization_idandscopeswhen present.
Registered claims are not copied into Actor.Claims. Non-registered custom claims are passed through.
Refresh-Token Rotation
A successful refresh follows this sequence:
- Parse and validate the refresh JWT: signature,
iss,aud, andtoken_type=refresh_token. - Hash the incoming refresh token with SHA-256 and look up the stored record.
- Reject unknown, revoked, or expired records.
- Load the session and reject it when missing or expired.
- Require
session_idto match the stored record anduser_idto match the session owner. - Carry non-reserved custom claims from the old refresh token.
- Generate a new access token and refresh token with new
jtivalues and currentiss/aud. - Atomically revoke the old token and insert the new record in one transaction.
- Return the new token pair.
Refresh-token JWT expiry is not evaluated during parsing. The stored refresh_tokens.expires_at value and the session expiry are authoritative. This keeps reuse detection working for tokens whose record has not yet expired.
If no row is updated, another request already rotated the token. The service re-reads the record and handles the request as token reuse. It never performs a second rotation.
Replay Handling
A revoked refresh token is never exchanged for credentials.
| When | Behavior | Event |
|---|---|---|
Within refresh_grace_period | Reject with 401. No new tokens. Session stays active. | jwt.token.reuse.throttled |
After refresh_grace_period | Reject with 401, revoke every refresh token for the session, blacklist the session when available, and delete the session. | jwt.token.reuse.malicious |
After malicious reuse, the user must sign in again.
The jwt.token.reuse.recovered event type and constant remain for compatibility, but the recovery flow has been removed and the event is no longer emitted.
Key Management and Rotation
At initialization:
- If no key exists, an Ed25519 key pair is generated.
- The private key is encrypted with the application secret and stored.
- The public key is stored in PEM form.
- If the newest key is older than
key_rotation_interval, rotation runs.
Rotation:
- Every existing non-expired key is marked with
expires_at = now + key_rotation_grace_period. - A new Ed25519 key pair is generated, encrypted, and stored.
- The JWKS cache is invalidated and repopulated.
JWKS contains keys with no expiry or a future expiry. Tokens signed with an old key remain verifiable until that key's grace period ends. After expiry, the key is omitted from JWKS.
Expired key rows are not deleted automatically. PurgeExpiredKeys can remove keys that expired more than 24 hours ago, but the plugin does not schedule it.
Rotation is checked only during plugin initialization. A long-running process keeps signing with its current key until it restarts or is redeployed.
Events
| Event | When | Useful Payload |
|---|---|---|
jwt.token.reuse.throttled | Revoked token replayed inside the grace window. | session_id, token_hash, delta_ms, grace_period_config, metadata, timestamp |
jwt.token.reuse.malicious | Revoked token replayed after the grace window. | Same fields; the session was invalidated. |
Events are published asynchronously when an event bus is configured. Audit metadata supports client_ip, user_agent, and device_id.
Example Usage
Standalone Mode
[[route_mappings]]
paths = [
"POST:/email-password/sign-in",
"POST:/email-password/sign-up",
]
plugins = ["jwt.respond_json"]Successful authentication then returns:
{
"access_token": "eyJhbGciOiJFZERTQSJ9...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "eyJhbGciOiJFZERTQSJ9..."
}Machine authentication returns only access_token, token_type, and expires_in.
The response hook deliberately skips requests that already set a redirect and responses that were already handled.
Library Mode
import (
authulaconfig "github.com/Authula/authula/config"
authulamodels "github.com/Authula/authula/models"
jwtplugin "github.com/Authula/authula/plugins/jwt"
)
config := authulaconfig.NewConfig(
authulaconfig.WithRouteMappings([]authulamodels.RouteMapping{
{
Paths: []string{
"POST:/email-password/sign-in",
"POST:/email-password/sign-up",
},
Plugins: []string{
jwtplugin.HookIDJWTRespondJSON.String(),
},
},
}),
)Security Recommendations
- Use HTTPS. Access and refresh tokens are bearer credentials.
- Set
issuerexplicitly in production. This prevents abase_urlchange from silently changing the issuer. - Keep
issuerandaudiencestable. Changing either invalidates every existing token. - Plan the hard cutover. Tokens issued before the upgrade are rejected. Deploy during a maintenance window and ask users and integrators to re-authenticate.
- Keep access tokens short-lived. The default is 15 minutes.
- Keep refresh rotation enabled. Clients must treat refresh tokens as single-use.
- Store refresh tokens securely. The current API returns them in the JSON response body. Treat them as secrets and avoid exposing them to untrusted scripts or logs.
- Use secondary storage in production. It enables blacklisting and the JWKS cache.
- Tune the refresh grace period. A small window (5–10 seconds) tolerates retries while treating later replays as attacks.
- Serialize refreshes on the client. Concurrent refreshes with the same token can trigger the reuse path.
- Restart or redeploy regularly if you depend on key rotation, because rotation is checked only at startup.
- Keep the key grace period longer than the access-token lifetime. Tokens signed just before rotation must remain verifiable.
- Validate
issandaudin downstream resource servers. A resource server that accepts Authula tokens must check both, not only the signature. - Use
organization_idfor organization or tenant scoping. Do not introduce a separate tenant claim. - Protect private keys. JWT private keys are encrypted at rest with the application secret. Protect that secret.
Troubleshooting
All tokens are rejected after upgrading
This is expected. Tokens issued before the upgrade have no iss or aud. Users must sign in again and machine clients must request new tokens. See Hard Cutover.
Tokens are not returned after sign-in
- Confirm
jwt.respond_jsonis present in the route mapping for the authentication route. - The response hook skips requests that set a redirect or are already handled.
- Confirm the JWT plugin is enabled and the authentication flow set an actor and session.
Refresh returns 401 invalid refresh token
- The token may have an invalid signature, issuer, audience, or
token_type. - The refresh token may be unknown, expired, or already rotated.
- The associated session may be missing or expired.
- The
session_idoruser_idclaim may not match the stored record or session. - The token may have been replayed after
refresh_grace_period, which invalidates the session.
Access is rejected because of the issuer
- Confirm
issuermatches the value used when the token was minted. - Confirm
base_urlandbase_pathhave not changed. - If
issueris not configured, the plugin derives it frombase_url + base_path.
Access is rejected because of the audience
- Confirm the token's
audcontains the configuredaudience. - A token minted for an external client or resource server is rejected by Authula's bearer plugin.
- If you run downstream services that validate Authula tokens, they must expect the same audience.
Automatic key rotation is not happening
Key rotation is evaluated during plugin initialization only. Restart or redeploy the process, or rotate keys through an operational process.
An old signing key is missing from JWKS
Old keys are published until expires_at. Once key_rotation_grace_period has elapsed, the key is excluded from JWKS.
Blacklisting is unavailable
Configure the Secondary Storage plugin. Without it, the blacklist service is not created and the JWKS cache is disabled.
Unexpected jwt.token.reuse.malicious events
Clients may be refreshing concurrently with the same token, or a token may have been replayed. Ensure clients serialize refresh requests and replace the stored refresh token after every successful refresh.
Notes
- The current implementation supports EdDSA only.
- Every token carries and validates
issandaud. audidentifies the resource server, not the client. Machine client identity is carried byclient_id.organization_idis the canonical organization/tenant identifier. There is no separatetenant_idclaim.- There is no background key-rotation scheduler.
- There is no scheduled refresh-token or expired-key cleanup. PostgreSQL and MySQL cascade refresh-token deletion when a session is deleted; SQLite does not enforce the foreign key.
- Refresh tokens are returned in the response body, not issued as HttpOnly cookies.
- No DPoP or mTLS binding is implemented.
- The
jwt.token.reuse.recoveredconstant and event type remain in the codebase for compatibility, but the event is not emitted. - Changing
issueroraudienceinvalidates all existing tokens. Treat it as a hard cutover.
Client SDK
When using the Authula Node SDK, register the JWT plugin:
import { createClient } from "authula";
import { JWTPlugin } from "authula/plugins";
export const authulaClient = createClient({
url: "http://localhost:8080/api/auth",
plugins: [
// other plugins...
new JWTPlugin(),
],
});Use the configured API base path in the client URL.
