Refresh Tokens

How the IAM service generates, stores, verifies, consumes, rotates, and revokes 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:

ColumnTypeDescription
idINT AUTO_INCREMENTPrimary key
user_idINT NOT NULLForeign key to users(id), cascades on delete
tokenVARCHAR(600) UNIQUESHA-256 hex hash of the raw token
validBOOLEAN DEFAULT 0Whether the token is still usable
created_atTIMESTAMPWhen the row was inserted
expiresAtTIMESTAMPComputed from refresh_ttl at insert time
usage_countINT DEFAULT 00 = fresh, 1 = consumed, used by reuse detection
session_started_atTIMESTAMPSet 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
)
ParameterTypeDescription
ttlnumberLifetime in milliseconds
userIdnumberUser ID to associate the token with
prevTsDate OR undefinedThe 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
}
FieldTypePresent when
validbooleanAlways
userIdnumberValid or expired
visitor_idstringValid
sessionStartedAtDateValid
expiresAtDateValid
reasonstringInvalid

verifyRefreshToken is not side-effect free. Inside a transaction, it:

  • Deletes rows where valid = 0 when a revoked token is presented
  • Marks expired tokens as valid = 0 and sets the user's last_mfa_at to NULL (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
}
FieldTypePresent when
validbooleanAlways
userIdnumberValid or expired
visitor_idstringValid
sessionTTLDateValid (the session_started_at value)
reasonstringInvalid

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.

In-place rotation relaxes the stricter model where expired tokens always require a new row. Make sure higher-level logic still enforces MAX_SESSION_LIFE, anomaly checks, and MFA before calling this function.
Be Very carful how you use 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:

  1. Consumes the refresh token with consumeAndVerifyRefreshToken (atomic, prevents post-logout reuse)
  2. Revokes it with revokeRefreshToken (sets valid = 0)
  3. Blocks the token hash in the rate limiter for the remaining refresh_ttl duration
  4. Verifies the access token and blacklists its jti for 24 hours
  5. Clears the session and iat cookies

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

OptionTypeDescription
refresh_ttlnumberToken lifetime in milliseconds. Used as the TTL for newly generated tokens
domainstringCookie domain for the session cookie (e.g. example.com)
MAX_SESSION_LIFEnumberMaximum session chain lifetime in milliseconds. Rotation is rejected when the original session_started_at is older than this
maxAllowedSessionsPerUsernumberMaximum concurrent valid refresh tokens per user before MFA is triggered
byPassAnomaliesFornumberMFA 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.
Logo