Tokens
The IAM service uses a two token architecture and a canary_id cookie for anomalies detection. A short-lived access token and A long-lived refresh token.
The access token is sent as a json field to clients, while clients needs to send it back in a Authorization: Bearer <token> header for verification.
Refresh tokens are long-lived, stored hashed with sha-256 in MySQL and sent via an httpOnly cookie to clients. Clients needs to send this cookie back for verification.
The canary_id cookie, is generated by the bot-detector, and should be included by clients on any sensitive request. This cookie is tied to the token family, and invalid cookie will be flagged.
Access Tokens
Access tokens are signed JWTs. The service signs them with the jwt.jwt_secret_key configured in configuration() and caches verified tokens in an LRU cache for the duration of their validity.
Generating an access token
When using the service as a library, you can generate an access token with generateAccessToken:
import { generateAccessToken } from '@riavzon/auth'
const token = generateAccessToken({
id: user.id,
visitor_id: user.visitor_id,
jti: crypto.randomUUID(),
roles: ["user", "admin"]
})
When using the standalone service, access tokens are sent to clients when they:
- Make an account
- Login to their account
- Try to rotate their session
- Proof their identity via an MFA flow.
Verifying an access token
When using the service as a library:
import { verifyAccessToken } from '@riavzon/auth'
const { valid, payload, errorType } = verifyAccessToken(token)
if (!valid) {
// errorType is 'TokenExpiredError' for expired tokens
// or a description string for other failures
}
| Field | Type | Description |
|---|---|---|
valid | boolean | Whether the token passed signature and expiry checks |
payload | JwtPayload | undefined | Decoded claims when valid |
errorType | string | undefined | Error description when invalid |
Token payload
The default payload includes visitor, roles, sub, jti, iat, and exp. Additional claims are merged from jwt.access_tokens.payload in the configuration.
{
"visitor": "vis_abc123",
"roles": ["user", "admin"],
"sub": "42",
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iat": 1710000000,
"exp": 1710000900
}
When using the service directly, you should send access tokens to the /secret/data route.
This route requires the access token header, canary_id cookie, and the refresh token cookie.
When verification passes, you get back a json data with the following fields:
{
"userId": 1234,
"authorized": true,
"ipAddress": "1.1.1.1",
"userAgent": "client user agent",
"date": "iso formatted date",
"roles": ["admin", "user", "etc"]
}
Cache
The access token cache is an LRU cache keyed by the signed token string. Its size is controlled by jwt.access_tokens.maxCacheEntries. Import the cache directly if you need to inspect or clear it:
import { tokenCache } from '@riavzon/auth'
const cache = tokenCache()
cache.delete(rawToken)
Apart from the signed verification, newly generated token are cached, and verified in the verification process, meaning even if an access token passes the cryptographic verification, but doesn't exists in the cache, it fails verification. This design allows you to revoke any valid access token, by simply deleting its cache.
Learn more in the access token page.
Refresh Tokens
Refresh tokens are opaque random 64 bytes hex strings. The service stores a SHA-256 hash of each token in MySQL alongside usage metadata. The raw token is sent to the client only once, as an httpOnly cookie, and never stored in plaintext.
Generating a refresh token
import { generateRefreshToken } from '@riavzon/auth'
const { raw, expiresAt } = await generateRefreshToken(
config.jwt.refresh_tokens.refresh_ttl,
user.id,
prevTs // optional
)
// Send `raw` to the client via Set-Cookie
// `expiresAt` is the Date at which the token expires
When using the standalone service, refresh tokens are sent to clients when they:
- Make an account
- Login to their account
- Try to rotate their session
- Proof their identity via an MFA flow.
Verifying a refresh token for library users
Use verifyRefreshToken for validation without token consumption. It can still perform side effects, such as invalidating expired tokens or deleting revoked token rows.
import { verifyRefreshToken } from '@riavzon/auth'
const result = await verifyRefreshToken(clientToken)
if (result.valid) {
const { userId, visitor_id, sessionStartedAt, expiresAt } = result
}
verifyRefreshToken Is not recomended for production uses, its existent is solely for convenience, and quick verification.
Use consumeAndVerifyRefreshTokenConsuming a refresh token
Use consumeAndVerifyRefreshToken for verification, rotation and reuse detection.
This function marks the token as consumed after the first use. A second attempt with the same token revokes all sessions for that user and returns valid: false.
import { consumeAndVerifyRefreshToken } from '@riavzon/auth'
const result = await consumeAndVerifyRefreshToken(clientToken)
if (!result.valid) {
// result.reason describes why verification failed
}
This is handled automatically for standalone service users.
Revoking a refresh token
Logout and anomaly handlers call revokeRefreshToken to invalidate a specific session.
import { revokeRefreshToken } from '@riavzon/auth'
const { success } = await revokeRefreshToken(clientToken)
Token Rotation
Rotation happens only via the /auth/user/refresh-session route and clients should call it directly with 2 credentials:
canary_idcookie - generated by thebot-detector- refresh token - the refresh token cookie
session
Anomalies such as reuse detection, invalid canary_id, or client IP range and fingerprint mismatches are flagged. Some anomalies trigger MFA (202), while others return immediate re-login (401) and may revoke refresh tokens. Learn more at anomalies
When the credentials are valid, and no anomalies are detected:
- The old refresh token is marked as consumed
- New refresh token is generated and sended to the client as a cookie, along side
iatcookie - New Access token is generated and sended to the client as a
accessTokenjson field
Example success response:
{
"message": "Refresh & access tokens rotated",
"accessToken": "<token>",
"accessIat": "Date.now().toString()"
}
You can also call the rotation helpers directly when you use the library:
import { rotateOneUseRefreshToken, rotateInPlaceRefreshToken } from '@riavzon/auth'
await rotateOneUseRefreshToken(config.jwt.refresh_tokens.refresh_ttl, userId, oldToken)
await rotateInPlaceRefreshToken(config.jwt.refresh_tokens.refresh_ttl, userId, oldToken)
await consumeAndVerifyRefreshToken(clientToken)
Reuse Detection
The system employs several mechanism to detect reuse and anomalies described in anomalies in details, in a brief, the system ties the token family to the user unique fingerprints, made available by the bot-detector and enhanced:
Scenario 1: An attacker got a valid refresh token / access token.
In order to use this token, the attacker would need to perfectly copy the user fingerprints + the fingerprint identifier canary_id, to avoid an MFA challenge:
- Attacker uses the token = fingerprint mismatch? MFA.
- Attacker uses the token and the fingerprints is valid the token usage_count is now 1.
- Legitimate user tries to login/rotate/logout or use the token.
- Reuse is detected.
- The account is locked and the user would need to login again, and complete an MFA challenge.
Scenario 2: An attacker obtains a refresh token that has already been rotated by the legitimate user.
In this "stale token" scenario, the attacker is immediately caught by the reuse detection logic:
- The moment the attacker attempts to use the previously rotated token, the system identifies it as a reuse violation.
- The system assumes the entire token family is compromised. It invalidates the attacker’s token and the legitimate user’s current session instantly.
- Both parties are forced out. The legitimate user is prompted to re-authenticate via a full login and MFA flow, effectively locking the attacker out of the account since they lack access to the user's secondary factors (Email/MFA).