Access Tokens
Access tokens are short-lived signed JWTs. The service uses them as the primary credential for protected routes. Every access token is paired with a refresh token and a canary_id cookie, none of the three work in isolation.
The service signs tokens with jwt.jwt_secret_key and caches every verified token in an LRU cache. Verification requires the token to both pass the cryptographic check and exist in that cache. This design gives you a cheap revocation path without touching the database.
Payload
When you call generateAccessToken, the service builds the payload from three sources merged in order: the fixed identity fields, your jwt.access_tokens.payload config object, and the token-specific claims the JWT library adds.
{
"visitor": "vis_abc123",
"roles": ["user", "admin"],
"sub": "42",
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iat": 1710000000,
"exp": 1710000900
}
| Claim | Source | Description |
|---|---|---|
visitor | user.visitor_id argument | Visitor session identifier, validated on every verify call |
roles | user.role argument | Role array embedded at signing time |
sub | user.id argument | Numeric user ID as a string |
jti | user.jti argument | UUID, used by the cache and blacklist |
iat / exp | jsonwebtoken library | Issued-at and expiry, driven by access_tokens.expiresIn |
Any keys you place in jwt.access_tokens.payload in the configuration are added into the payload before signing.
Generating a Token
import { generateAccessToken } from '@riavzon/auth'
import crypto from 'crypto'
const token = generateAccessToken({
id: user.id,
visitor_id: user.visitor_id,
jti: crypto.randomUUID(),
role: ['user', 'admin']
})
generateAccessToken signs the token, then immediately writes a cache entry keyed by the raw token string. The cache entry stores jti, visitorId, userId, and roles alongside a valid: true flag. Any route calling verifyAccessToken later checks this entry before running the cryptographic verification.
When using the standalone service, the service generates and sends access tokens automatically on signup, login, MFA completion, and token rotation. You do not call generateAccessToken directly in that mode.
Verifying a Token
import { verifyAccessToken } from '@riavzon/auth'
const { valid, payload, errorType } = verifyAccessToken(token)
if (!valid) {
console.log(errorType)
}
| Field | Type | Description |
|---|---|---|
valid | boolean | True when both the cache check and cryptographic check pass |
payload | JwtPayload | undefined | Decoded claims, present only when valid |
errorType | string | undefined | Failure description when invalid |
Verification runs in this order:
Cache lookup
The function reads the cache entry for the token string. If no entry exists, or if valid is false, it returns immediately with InvalidPayloadType.
Cryptographic verification
Runs with the configured algorithm, audience, issuer, subject, and jwtid. A mismatch on any of those claims fails verification.
Visitor binding
The visitor claim in the decoded payload is compared against the visitorId stored in the cache entry. This catches tokens that have been tampered with or replayed in a different session.
Role comparison
compareRoles runs against the roles array in the cache and the roles array in the decoded claim. Both must match exactly, no extra roles, no missing roles. This prevents tokens from gaining permissions they were not issued with.
The possible errorType values are:
| Value | Cause |
|---|---|
InvalidPayloadType | Token not in cache, or valid flag is false |
TokenExpiredError | Token has expired; the cache entry is deleted automatically |
Invalid visitor id | visitor claim does not match the cached visitorId |
MalformedPayload | roles in the token or cache is not a valid string array, or contains duplicates |
InvalidRoles | roles in the token do not exactly match the roles in the cache |
invalid token / jwt malformed / invalid signature | Standard jsonwebtoken failures |
Cache
The access token cache is an LRU cache, lru-cache keyed by the raw token string. Its maximum size and TTL are driven by configuration:
{
maxCacheEntries: 500,
expiresInMs: 900000
}
You can read or write the cache directly through the exported tokenCache function:
import { tokenCache } from '@riavzon/auth'
const cache = tokenCache()
cache.delete(rawToken)
const entry = cache.get(rawToken)
Deleting a cache entry is enough to revoke an access token. The next verification call for that token will fail at the cache step before any cryptographic work happens.
Route Protection
The protectRoute Middleware
protectRoute is the middleware that ties together access token verification, anomaly detection, and req.user population.
Mount it in this order:
requireAccessTokenrequireRefreshTokengetFingerPrintcheckForActiveMfa,protectRoute,<your-route>
import {
requireAccessToken,
requireRefreshToken,
getFingerPrint,
checkForActiveMfa,
protectRoute
} from '@riavzon/auth'
router.get(
'/your/protected/route',
requireAccessToken,
requireRefreshToken,
getFingerPrint,
checkForActiveMfa,
protectRoute,
async (req, res) => {
const { userId, visitor_id, roles, accessTokenId } = req.user!
res.json({ userId, roles })
}
)
When protectRoute completes successfully it attaches the following to req.user:
| Field | Type | Description |
|---|---|---|
userId | string | The sub claim from the token |
visitor_id | string | The visitor claim |
accessTokenId | string | The jti claim, use this to blacklist the token early |
roles | string[] | Roles from the verified payload |
payload | JwtPayload | The full decoded payload |
protectRoute also runs anomalies checks against the refresh token and canary_id cookie. If the anomaly engine flags the session, the middleware either triggers an MFA challenge (202) or demands re-login (401) before your handler is reached. See anomalies for details.
The BFF Route
When running the standalone service, the bffAccessRoute router exposes two GET endpoints that perform access-checking on behalf of a backend-for-frontend:
| Route | Description |
|---|---|
GET /secret/data | Returns { authorized, userId, ipAddress, userAgent, date, roles } after full token and anomaly verification |
GET /secret/accesstoken/metadata | Returns the decoded payload, milliseconds until expiry, and a shouldRotate boolean |
The metadata endpoint calculates a refresh threshold at 25% of the configured TTL. When shouldRotate is true, the client should call the rotation endpoint before the token expires:
{
"authorized": true,
"payload": { "decoded claims" },
"msUntilExp": 210000,
"refreshThreshold": 225000,
"shouldRotate": true
}
You need to call both with the access token header, the canary_id and the refresh token cookie.
Learn more at Backend for frontend
Roles
Roles are embedded in the token at generation time and verified cryptographically on every call to verifyAccessToken. The roles array in the cache must equal the roles array in the token claim, with no extras and no missing entries.
Embedding Roles
Pass an array of role strings as the role field when calling generateAccessToken. The service stores that array in the LRU cache entry and embeds it as the roles claim in the signed token.
const token = generateAccessToken({
id: user.id,
visitor_id: user.visitor_id,
jti: crypto.randomUUID(),
role: ['user', 'admin', 'editor']
})
The standalone service generates tokens without any roles by default. To attach roles, call generateAccessToken directly, after user authentication.
Role Validation
compareRoles runs inside verifyAccessToken automatically. It normalizes both arrays with a trimming pass, checks for duplicates before and after normalization, then compares the sets:
required (from cache) = ['user', 'admin']
provided (from token) = ['user', 'admin'] passes
provided = ['user', 'admin', 'editor'] InvalidRoles
provided = ['user'] InvalidRoles
If you need different roles per route, generate separate tokens for each role context rather than relying on runtime role filtering, the library treats the embedded role array as an identity commitment, not a permission mask.
accessTokenId (jti) attached to req.user by protectRoute can be passed to the rate limiter blacklist to revoke a specific token mid-session without touching the database. This is the recommended approach for forced logout of individual sessions.Configuration Reference
All access token options live under jwt.access_tokens in the object passed to configuration().
| Option | Type | Default | Description |
|---|---|---|---|
expiresIn | string | number | '15m' | Token lifetime. Accepts vercel/ms strings or seconds |
expiresInMs | number | 900000 | Used for cache TTL and metadata endpoint calculations. Should match expiresIn in milliseconds |
algorithm | string | 'HS512' | HMAC or RSA/EC algorithm. Accepts any algorithm supported by jsonwebtoken |
audience | string | refresh_tokens.domain | JWT aud claim |
issuer | string | refresh_tokens.domain | JWT iss claim |
subject | string | user.id.toString() | JWT sub claim override |
jwtid | string | user.jti | JWT jti claim override |
maxCacheEntries | number | 500 | LRU cache maximum size |
payload | Record<string, unknown> | {} | Static custom claims spread into every token payload |
expiresIn and expiresInMs must be kept in sync manually. The library uses expiresIn for the JWT signature and expiresInMs for the LRU cache TTL and the metadata endpoint threshold calculation. A mismatch causes the cache to evict tokens before they expire, or hold them after, breaking the two-gate verification model.