Cookies
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.
Cookie Conventions
| Cookie | Set by | Value | Purpose |
|---|---|---|---|
session | IAM service | Raw refresh token, 64-byte hex string | Authenticates refresh and logout requests |
iat | IAM service | Date.now().toString(), milliseconds | Records when the last token pair was issued. Used by clients to decide when to trigger rotation. |
canary_id | Bot-detector | Device fingerprint identifier, up to 64 chars | Links the request to a visitors table row. Used for anomaly detection, signup validation, and user creation. |
session Cookie
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
| Option | Value | Reason |
|---|---|---|
httpOnly | true | Prevents JavaScript access. Mitigates XSS token theft. |
secure | true | Only transmitted over HTTPS. |
sameSite | strict | Only sent on same-site requests. Prevents CSRF. |
domain | jwt.refresh_tokens.domain | Scoped to the configured domain (e.g., .example.com for cross-subdomain sharing). |
path | / | Available on all paths. |
expires | refreshToken.expiresAt | Matches 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:
| Flow | Controller | Trigger |
|---|---|---|
| Login | loginController | Successful email/password authentication |
| Signup | signUpController | New account creation |
| Token rotation | rotateCredentials | POST /auth/user/refresh-session |
| OAuth | OAuth controller | Successful OAuth provider callback |
| MFA completion | verifyMfaCode | Successful 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.
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.iat Cookie
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
| Option | Value | Reason |
|---|---|---|
httpOnly | true | Not accessible to JavaScript. |
secure | true | HTTPS only. |
sameSite | strict | Same-site only. |
domain | Not set | Unlike session, the iat cookie does not specify a domain. It defaults to the exact hostname that set it. |
path | / | Available on all paths. |
expires | refreshToken.expiresAt | Expires 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.
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.canary_id Cookie - Read-Only
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
| Consumer | Purpose |
|---|---|
| Signup guard | Validates that canary_id is present in the request. Returns 400 if missing, because the visitor record is required for user creation. |
| Login guard | Same validation. Returns 400 if missing. |
| OAuth guard | Same 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. |
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:
| Prefix | Enforced Options | Effect |
|---|---|---|
__Secure- | secure: true | The function forces secure to true regardless of what was passed in options. |
__Host- | secure: true, path: '/', domain deleted | The 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.
Cookie Middleware
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-sessionrotationPOST /auth/logoutlogout
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:
Validate cookie presence
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.Cookie Lifecycle Summary
The diagram below shows when each cookie is created and destroyed across the authentication lifecycle:
| Event | session | iat | canary_id |
|---|---|---|---|
| First visit (bot-detector) | Created | ||
| Signup | Created | Created | Read |
| Login | Created | Created | Read |
| OAuth callback | Created | Created | Read |
| MFA verification | Created | Created | Read |
| Token rotation | Cleared, then re-created | Cleared, then re-created | Read |
| Logout | Cleared | Cleared | Unchanged |
| Session expiry (during rotation) | Cleared | Cleared | Unchanged |
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
| Property | session | iat | canary_id |
|---|---|---|---|
httpOnly | Yes | Yes | Yes |
secure | Yes | Yes | Yes |
sameSite | strict | strict | lax |
| Accessible to JavaScript | No | No | No |
| Transmitted over HTTP | No | No | No |
| Sent cross-site | No | No | Yes |
| Contains sensitive data | Yes | No | Yes |
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.