Refresh Tokens
Refresh tokens are opaque random strings, 64 bytes, hex encoded. The service stores a SHA-256 hash of each token in a MySQL refresh_tokens table alongside usage metadata. The raw token is sent to the client exactly once, as an httpOnly cookie named session, and never stored in plaintext on the server.
Every refresh token has a usage_count column. That column is the core of the reuse detection system. A token starts at 0, gets atomically changed to 1 on consumption, and any second attempt with the same token revokes every active session for that user.
The Database Row
Each token maps to one row in the refresh_tokens table. Here is what gets stored when generateRefreshToken runs:
| Column | Type | Description |
|---|---|---|
id | INT AUTO_INCREMENT | Primary key |
user_id | INT NOT NULL | Foreign key to users(id), cascades on delete |
token | VARCHAR(600) UNIQUE | SHA-256 hex hash of the raw token |
valid | BOOLEAN DEFAULT 0 | Whether the token is still usable |
created_at | TIMESTAMP | When the row was inserted |
expiresAt | TIMESTAMP | Computed from refresh_ttl at insert time |
usage_count | INT DEFAULT 0 | 0 = fresh, 1 = consumed, used by reuse detection |
session_started_at | TIMESTAMP | Set once at creation and carried forward to each new token on rotation. Used for MAX_SESSION_LIFE enforcement |
The token column is unique. The user_id foreign key cascades on delete, so deleting a user wipes all their sessions automatically.
Generating a Token
import { generateRefreshToken } from '@riavzon/auth'
const { raw, expiresAt } = await generateRefreshToken(
config.jwt.refresh_tokens.refresh_ttl,
user.id,
prevTs // optional
)
| Parameter | Type | Description |
|---|---|---|
ttl | number | Lifetime in milliseconds |
userId | number | User ID to associate the token with |
prevTs | Date OR undefined | The previous timestamp of the session to set in the db, defaults to UTC_TIMESTAMP() |
The function generates 64 random bytes, hex encodes them, computes the SHA-256 hash with toDigestHex, then inserts a row with valid = 1 and expiresAt computed from the provided TTL. It returns the raw unhashed token and the expiry date. That raw value is what goes into the session cookie. The server never sees it again after this point.
When using the standalone service, the service generates and sends refresh tokens automatically on signup, login, MFA completion, and token rotation. You do not call generateRefreshToken directly in that mode.
Hashing
All refresh token functions accept a raw token string and hash it internally before any database operation. The toDigestHex utility is a function that checks whether the input is already a SHA-256 hex string. If it is, it passes through. If not, it computes sha256(input) and returns the digest. This makes every function idempotent with respect to hashing, you can pass either form without breaking.
After toDigestHex, ensureSha256Hex runs a strict regex check (/^[a-f0-9]{64}$/i) and throws if the value is malformed. This double gate prevents truncated or corrupted hashes from reaching the database.
Verification
The service exposes two verification functions with very different semantics.
verifyRefreshToken
Read-only verification. Does not change usage_count. Suitable for quick checks where you plan to revoke the token yourself immediately after.
import { verifyRefreshToken } from '@riavzon/auth'
const result = await verifyRefreshToken(clientToken)
if (result.valid) {
const { userId, visitor_id, sessionStartedAt, expiresAt } = result
}
| Field | Type | Present when |
|---|---|---|
valid | boolean | Always |
userId | number | Valid or expired |
visitor_id | string | Valid |
sessionStartedAt | Date | Valid |
expiresAt | Date | Valid |
reason | string | Invalid |
verifyRefreshToken is not side-effect free. Inside a transaction, it:
- Deletes rows where
valid = 0when a revoked token is presented - Marks expired tokens as
valid = 0and sets the user'slast_mfa_attoNULL(forces MFA on next login)
verifyRefreshToken is not recommended for production consumption paths. It exists for convenience and quick verification. Use consumeAndVerifyRefreshToken for any path where the token should be invalidated after use.consumeAndVerifyRefreshToken
Atomic verification and consumption. This is the function used by the rotation and logout controllers.
import { consumeAndVerifyRefreshToken } from '@riavzon/auth'
const result = await consumeAndVerifyRefreshToken(clientToken)
if (!result.valid) {
// result.reason describes the failure
}
| Field | Type | Present when |
|---|---|---|
valid | boolean | Always |
userId | number | Valid or expired |
visitor_id | string | Valid |
sessionTTL | Date | Valid (the session_started_at value) |
reason | string | Invalid |
The consumption happens in a single atomic UPDATE inside a transaction. It increments usage_count by one, but only if the token exists, is still valid, has never been consumed (usage_count = 0), and has not expired. All four conditions must pass for the row to be affected.
If no row was affected, the function investigates why by selecting the row with FOR UPDATE:
Token not found
No row in the table. Returns { valid: false, reason: 'Token not found' }.
Token revoked
Row exists but valid = 0. Returns { valid: false, reason: 'Token has been revoked' }.
Reuse detected
Row exists, valid = 1, but usage_count > 0. This means someone already consumed this token. The function immediately revokes all refresh tokens for that user and returns { valid: false, reason: 'Token already used, Please login again' }.
If affectedRows === 1, the token was successfully consumed. The function then fetches the full row (joining users for visitor_id), runs the same expired and revoked checks as verifyRefreshToken, and returns the valid result.
Revocation
Single token
import { revokeRefreshToken } from '@riavzon/auth'
const { success } = await revokeRefreshToken(clientToken)
Sets valid = 0 on the matching row. Does not delete the row, the row stays around so reuse detection can still identify it later.
All user sessions
import { revokeAllRefreshTokens } from '@riavzon/auth'
const { success } = await revokeAllRefreshTokens(userId)
Marks every valid refresh token for the given user as invalid. Used by the reuse detection path inside consumeAndVerifyRefreshToken and exposed for manual use when you need to force-logout a user from all devices.
Rotation
Rotation is the process of consuming the current refresh token and issuing a new one. The service provides two rotation helpers for library users and one route for standalone service users.
rotateOneUseRefreshToken
Verifies the old token, revokes it, then generates a fresh token with a new row.
import { rotateOneUseRefreshToken } from '@riavzon/auth'
const result = await rotateOneUseRefreshToken(
config.jwt.refresh_tokens.refresh_ttl,
user.id,
oldRawToken
)
if (result.rotated) {
// result.raw = new token, result.expiresAt = new expiry
}
This helper calls verifyRefreshToken (not consumeAndVerifyRefreshToken), then revokeRefreshToken, then generateRefreshToken. It validates that the userId argument matches the token's user_id before proceeding.
rotateInPlaceRefreshToken
Updates an existing expired row in place rather than creating a new row. Only succeeds when the token is both invalid and expired.
import { rotateInPlaceRefreshToken } from '@riavzon/auth'
const result = await rotateInPlaceRefreshToken(
config.jwt.refresh_tokens.refresh_ttl,
user.id,
oldRawToken
)
The function generates a new random token, hashes it, and replaces the old token hash, expiry, and validity flag on the existing row. The update only affects the row if it belongs to the specified user, is currently invalid, and has already expired. If all conditions are met, the row becomes valid again with a fresh token and a new TTL.
MAX_SESSION_LIFE, anomaly checks, and MFA before calling this function.rotateInPlaceRefreshToken, and rotateOneUseRefreshToken it completely bypass the reuse detection.
Both of these 2 not used in the life-cycles of the sessions.The Rotation Route
Standalone service users call POST /auth/user/refresh-session to rotate. The middleware chain is:
requireRefreshToken, acceptCookieOnly, getFingerPrint, checkForActiveMfa rotateCredentials
rotateCredentials is the controller. It runs in this order:
Rate limiting
Guards against brute force with three limiters layered: IP limiter, token hash limiter, and a composite ip_tokenhash limiter. Each uses guard() with consecutive caches that escalate block duration on repeated violation.
Anomaly detection
Calls strangeThings(rawRefreshToken, canary_id, ip, userAgent, true) with rotated = true. The true flag tells the anomaly engine to also check usage_count > 0 as a hard rejection. If anomalies are detected, the engine either triggers MFA (202) or demands re-login (401).
Consume the old token
Calls consumeAndVerifyRefreshToken(rawRefreshToken). If the token is valid but session_started_at is older than MAX_SESSION_LIFE, the controller revokes it, clears both session and iat cookies, and returns 401 Session is expired.
Revoke the old token
Calls revokeRefreshToken(rawRefreshToken) to set valid = 0.
Issue new credentials
Generates a new refresh token with generateRefreshToken(refresh_ttl, userId, sessionTTL) and a new access token with generateAccessToken, the sessionTTL comes from the result of consumeAndVerifyRefreshToken(rawRefreshToken). Sets the session cookie (with the new refresh token) and the iat cookie (with Date.now()). Blocks the old token hash in the rate limiter for 3 days.
Success response (201):
{
"message": "Refresh & access tokens rotated",
"accessToken": "<signed jwt>",
"accessIat": "1710000000000"
}
Logout
Standalone service users call POST /auth/logout. The middleware chain is:
requireRefreshToken, requireAccessToken, acceptCookieOnly, handleLogout
The logout controller:
- Consumes the refresh token with
consumeAndVerifyRefreshToken(atomic, prevents post-logout reuse) - Revokes it with
revokeRefreshToken(setsvalid = 0) - Blocks the token hash in the rate limiter for the remaining
refresh_ttlduration - Verifies the access token and blacklists its
jtifor 24 hours - Clears the
sessionandiatcookies
Success response (200):
{ "ok": true, "message": "Logged out successfully" }
Reuse Detection
The reuse detection system relies on two mechanisms working together: the usage_count column in consumeAndVerifyRefreshToken, and the anomaly engine's rotated flag check.
Through consumeAndVerifyRefreshToken
When a token has usage_count > 0 and someone tries to consume it again, the function assumes the token family is compromised. It revokes all valid refresh tokens for that user and returns valid: false. Both the attacker and the legitimate user are forced to re-authenticate.
Through the anomaly engine
When strangeThings runs with rotated = true (the rotation route), it checks usage_count > 0 as a hard rejection alongside its other checks. If the token was already consumed, the anomaly engine returns valid: false, reqMFA: false, which the rotation controller translates to a 401 re-login response.
Scenario walkthrough
An attacker obtains a valid, unconsumed refresh token.
The attacker needs to replicate the user's exact fingerprint and canary_id cookie to avoid triggering MFA. If the fingerprint does not match, the anomaly engine flags it and sends an MFA challenge to the real user's email. If the attacker somehow passes the fingerprint check and consumes the token (usage_count goes to 1), the legitimate user's next rotation attempt hits usage_count > 0 and all sessions are revoked.
An attacker obtains a previously rotated (stale) token.
The token already has usage_count >= 1. The attacker's attempt to consume it immediately triggers the revoke-all path. The legitimate user's current valid token is also invalidated. Both parties must re-authenticate, and the attacker cannot complete MFA without access to the user's email.
Session Lifetime
Refresh tokens have two independent lifetime controls:
Token TTL (refresh_ttl)
Set in milliseconds in the configuration. Controls the expiresAt column on each row. When a token expires, both verifyRefreshToken and consumeAndVerifyRefreshToken mark it valid = 0 and set the user's last_mfa_at to NULL. This resets the byPassAnomaliesFor cooldown window, meaning the next time the user hits any anomaly condition (too many sessions, IP mismatch, canary mismatch, etc.) it will not be bypassed. A clean login from the same device with no anomalies still passes without MFA.
Session lifetime (MAX_SESSION_LIFE)
Also in milliseconds. The rotation controller compares Date.now() - session_started_at against this value. If the session has been alive longer than MAX_SESSION_LIFE, the controller revokes the token and returns 401 Session is expired, even if the token itself has not expired yet.
The distinction matters: refresh_ttl controls how long a single token lives. MAX_SESSION_LIFE controls how long the session chain (original token plus all its rotated successors) can stay alive, both controlled in the configuration.
Session Limits
The anomaly engine enforces two session-count rules, both configured under jwt.refresh_tokens:
maxAllowedSessionsPerUser limits the number of concurrent valid sessions. When a user exceeds this count, the anomaly engine flags the next rotation or access-check as requiring MFA. The user must prove their identity before the service issues new tokens.
byPassAnomaliesFor is a cooldown period in milliseconds. If the user completed MFA within that window (last_mfa_at + byPassAnomaliesFor > now), the session-count check is skipped. This prevents MFA spam when a user legitimately logs in from multiple devices in quick succession.
The anomaly engine also flags rapid token creation: if more than 3 valid tokens were created in the last 10 minutes for the same user, the presented token is revoked and the session is terminated without MFA (hard 401).
More details at anomalies
Configuration Reference
All refresh token options live under jwt.refresh_tokens in the object passed to configuration().
| Option | Type | Description |
|---|---|---|
refresh_ttl | number | Token lifetime in milliseconds. Used as the TTL for newly generated tokens |
domain | string | Cookie domain for the session cookie (e.g. example.com) |
MAX_SESSION_LIFE | number | Maximum session chain lifetime in milliseconds. Rotation is rejected when the original session_started_at is older than this |
maxAllowedSessionsPerUser | number | Maximum concurrent valid refresh tokens per user before MFA is triggered |
byPassAnomaliesFor | number | MFA cooldown in milliseconds. Session-count anomalies are skipped if last_mfa_at is within this window |
refresh_ttl and MAX_SESSION_LIFE serve different purposes. refresh_ttl is how long one token lives. MAX_SESSION_LIFE is how long the entire session chain can survive across rotations. Set MAX_SESSION_LIFE to something like 7 or 30 days depending on your security posture, and refresh_ttl to something shorter (e.g. 3 days). This forces periodic rotation while still capping absolute session duration.