Cookies

How the IAM service sets, reads, clears, and secures cookies including the session refresh token, the issued-at timestamp, and the bot-detector canary identifier.

The IAM service uses three cookies. It sets two of them session and iat, and reads one that it does not own, canary_id, which is set by the bot-detector. Every cookie the service writes is httpOnly, secure, and sameSite: strict. No cookie is ever accessible to client side JavaScript.

The session cookie carries the raw refresh token. The iat cookie carries the millisecond timestamp of when the credentials were last issued. Together, they let the client send refresh requests without storing sensitive values in localStorage or sessionStorage. The canary_id cookie is a device fingerprint created by the bot-detector and is consumed by the IAM service during signup, login, OAuth, and anomaly detection flows.


CookieSet byValuePurpose
sessionIAM serviceRaw refresh token, 64-byte hex stringAuthenticates refresh and logout requests
iatIAM serviceDate.now().toString(), millisecondsRecords when the last token pair was issued. Used by clients to decide when to trigger rotation.
canary_idBot-detectorDevice fingerprint identifier, up to 64 charsLinks the request to a visitors table row. Used for anomaly detection, signup validation, and user creation.

The session cookie is the most security-critical cookie in the system. It contains the raw, unhashed refresh token. The server stores only a SHA-256 hash of this value in the refresh_tokens table. Anyone who possesses the raw cookie value can request a new access token.

Options

OptionValueReason
httpOnlytruePrevents JavaScript access. Mitigates XSS token theft.
securetrueOnly transmitted over HTTPS.
sameSitestrictOnly sent on same-site requests. Prevents CSRF.
domainjwt.refresh_tokens.domainScoped to the configured domain (e.g., .example.com for cross-subdomain sharing).
path/Available on all paths.
expiresrefreshToken.expiresAtMatches the refresh token's database expiry. The cookie and the token expire at the same moment.

When It Is Set

The session cookie is written in every flow that issues a new refresh token:

FlowControllerTrigger
LoginloginControllerSuccessful email/password authentication
SignupsignUpControllerNew account creation
Token rotationrotateCredentialsPOST /auth/user/refresh-session
OAuthOAuth controllerSuccessful OAuth provider callback
MFA completionverifyMfaCodeSuccessful MFA code verification

In every case, the pattern is the same:

makeCookie(res, 'session', newRefresh.raw, {
  httpOnly: true,
  sameSite: 'strict',
  secure: true,
  domain: jwt.refresh_tokens.domain,
  path: '/',
  expires: newRefresh.expiresAt
})

When It Is Cleared

The session cookie is cleared in two scenarios:

Logout

The logout controller calls res.clearCookie('session', options) with the same httpOnly, sameSite, secure, domain, and path options used when setting it. Matching every option exactly is required by the browser's cookie deletion rules.

Session expiry during rotation

When the rotation controller detects that session_started_at exceeds MAX_SESSION_LIFE, it revokes the token, clears both session and iat, and returns 401 Session is expired. The user must log in again.

If any option in clearCookie does not match the original setCookie options (different domain, path, or sameSite), the browser silently ignores the deletion request and the cookie persists. The service always uses the same option set for both operations.

The iat (issued-at) cookie records the moment when the most recent token pair (access + refresh) was issued. The value is Date.now().toString(), a millisecond Unix timestamp as a string.

Options

OptionValueReason
httpOnlytrueNot accessible to JavaScript.
securetrueHTTPS only.
sameSitestrictSame-site only.
domainNot setUnlike session, the iat cookie does not specify a domain. It defaults to the exact hostname that set it.
path/Available on all paths.
expiresrefreshToken.expiresAtExpires alongside the refresh token.

Purpose

The iat cookie helps upstream services determine how recently credentials were issued without decoding the access token JWT. A BFF can read the iat value to decide whether a proactive rotation is needed before the access token expires.

When It Is Set

The iat cookie is always set alongside the session cookie. Every flow listed in the session cookie table above also sets iat:

makeCookie(res, 'iat', Date.now().toString(), {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  path: '/',
  expires: newRefresh.expiresAt
})

When It Is Cleared

The iat cookie is cleared alongside session during logout and session expiry. The clearCookie call uses the same httpOnly, sameSite, secure, and path options. For logout specifically, the domain is explicitly set to jwt.refresh_tokens.domain in the clear call to ensure the deletion reaches the correct scope.

The iat cookie is set without a domain but cleared with domain: jwt.refresh_tokens.domain in the logout controller. This inconsistency works because browsers treat a cookie set without a domain as belonging to the exact host, and a clear with a broader domain will match it if the host falls within that domain scope.

The IAM service reads but never writes the canary_id cookie. This cookie is set by the bot-detector middleware, which runs before the IAM service's routes.

How the IAM Service Uses It

ConsumerPurpose
Signup guardValidates that canary_id is present in the request. Returns 400 if missing, because the visitor record is required for user creation.
Login guardSame validation. Returns 400 if missing.
OAuth guardSame validation. Returns 400 if missing.
createUser()Uses the canary_id to look up the visitor_id from the visitors table, then stores it in the new user's visitor_id column.
trustVisitor()Marks the visitor record as trusted after successful authentication (clears suspicious activity flags).
strangeThings()The anomaly detection engine uses canary_id to load the full visitor record (IP, geo, device, proxy status, suspicion score) and compare it against the current request.
The canary_id cookie is a hard requirement for signup, login, and OAuth flows. If the bot-detector is not running or the cookie is missing, these endpoints return 400 without processing the request. You cannot create users without a valid visitor record.

makeCookie Helper

All cookie operations go through the makeCookie utility function. It wraps res.cookie() with automatic prefix enforcement for security-hardened cookie names.

function makeCookie(
  res: Response,
  name: string,
  value: string,
  options: CookieOptions
): void

Prefix Enforcement

The function checks the cookie name for two standard security prefixes and enforces the corresponding browser requirements:

PrefixEnforced OptionsEffect
__Secure-secure: trueThe function forces secure to true regardless of what was passed in options.
__Host-secure: true, path: '/', domain deletedThe function forces secure to true, sets path to '/', and removes any domain from the options. __Host- cookies cannot be scoped to a parent domain.

Currently, the IAM service uses plain cookie names (session, iat) without a prefix. The prefix enforcement exists for forward compatibility if the service adopts __Host-session or __Secure-session naming in the future. The enforcement logic runs on every call, so switching to prefixed names requires only changing the name string; the security constraints apply automatically.


cookieParser

The service mounts cookieParser() as Express middleware. It runs after express.json() and before any route handler:

httpLogger, helmet, headers, validateIp, hmacAuth?, json, cookieParser, routes

cookieParser parses the Cookie header and populates req.cookies as a key-value object. Every route handler downstream can access req.cookies.session, req.cookies.iat, and req.cookies.canary_id directly.

requireRefreshToken

A middleware guard that checks for the session cookie before allowing the request to proceed. If req.cookies.session is missing or empty, the middleware returns 401 immediately. Used on routes that consume the refresh token:

  • POST /auth/user/refresh-session rotation
  • POST /auth/logout logout

acceptCookieOnly

A stricter middleware guard for endpoints where the cookie should be the only input. It rejects requests that include a body, query parameters, or a Content-Type header. Exported from @riavzon/auth as acceptCookieOnly.

The acceptCookieOnly middleware performs these checks:

Returns 401 if req.cookies.session is missing or not a string.

Reject request body

Returns 400 if any of these conditions are true: req.body contains keys, the Content-Length header is greater than zero, or the Transfer-Encoding header includes chunked. This triple check catches payloads even if body parsing fails or is bypassed.

Reject query parameters

Returns 400 if req.query contains any keys. The request should have no query string.

Reject Content-Type

Returns 400 if the Content-Type header is set. Cookie-only endpoints do not accept content.

acceptCookieOnly is applied to the rotation and logout routes to ensure the refresh token comes exclusively from the session cookie. This prevents token injection through the request body or query string, which would bypass the httpOnly protection of the cookie.

The diagram below shows when each cookie is created and destroyed across the authentication lifecycle:

Eventsessioniatcanary_id
First visit (bot-detector)Created
SignupCreatedCreatedRead
LoginCreatedCreatedRead
OAuth callbackCreatedCreatedRead
MFA verificationCreatedCreatedRead
Token rotationCleared, then re-createdCleared, then re-createdRead
LogoutClearedClearedUnchanged
Session expiry (during rotation)ClearedClearedUnchanged

Every creation event sets both session and iat together. They are always written as a pair, with the same expiry. The canary_id cookie is independent of the IAM session lifecycle; it persists across login/logout cycles and is managed entirely by the bot-detector.


Security Properties

Propertysessioniatcanary_id
httpOnlyYesYesYes
secureYesYesYes
sameSitestrictstrictlax
Accessible to JavaScriptNoNoNo
Transmitted over HTTPNoNoNo
Sent cross-siteNoNoYes
Contains sensitive dataYesNoYes
The session cookie carries the raw refresh token. If an attacker obtains this value, they can request new access tokens. The service mitigates this with Pino's redaction system, which replaces req.cookies.session with [SECRET] in HTTP logs. See Logging for the full list of redacted paths.
Logo