Access Tokens

How the IAM service generates, caches, verifies, and revokes access tokens - and how library users wire roles and route protection.

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
}
ClaimSourceDescription
visitoruser.visitor_id argumentVisitor session identifier, validated on every verify call
rolesuser.role argumentRole array embedded at signing time
subuser.id argumentNumeric user ID as a string
jtiuser.jti argumentUUID, used by the cache and blacklist
iat / expjsonwebtoken libraryIssued-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)
}
FieldTypeDescription
validbooleanTrue when both the cache check and cryptographic check pass
payloadJwtPayload | undefinedDecoded claims, present only when valid
errorTypestring | undefinedFailure 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:

ValueCause
InvalidPayloadTypeToken not in cache, or valid flag is false
TokenExpiredErrorToken has expired; the cache entry is deleted automatically
Invalid visitor idvisitor claim does not match the cached visitorId
MalformedPayloadroles in the token or cache is not a valid string array, or contains duplicates
InvalidRolesroles in the token do not exactly match the roles in the cache
invalid token / jwt malformed / invalid signatureStandard 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:

  • requireAccessToken
  • requireRefreshToken
  • getFingerPrint
  • checkForActiveMfa,
  • 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:

FieldTypeDescription
userIdstringThe sub claim from the token
visitor_idstringThe visitor claim
accessTokenIdstringThe jti claim, use this to blacklist the token early
rolesstring[]Roles from the verified payload
payloadJwtPayloadThe 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:

RouteDescription
GET /secret/dataReturns { authorized, userId, ipAddress, userAgent, date, roles } after full token and anomaly verification
GET /secret/accesstoken/metadataReturns 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.

The 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().

OptionTypeDefaultDescription
expiresInstring | number'15m'Token lifetime. Accepts vercel/ms strings or seconds
expiresInMsnumber900000Used for cache TTL and metadata endpoint calculations. Should match expiresIn in milliseconds
algorithmstring'HS512'HMAC or RSA/EC algorithm. Accepts any algorithm supported by jsonwebtoken
audiencestringrefresh_tokens.domainJWT aud claim
issuerstringrefresh_tokens.domainJWT iss claim
subjectstringuser.id.toString()JWT sub claim override
jwtidstringuser.jtiJWT jti claim override
maxCacheEntriesnumber500LRU cache maximum size
payloadRecord<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.
Logo