Login

How the IAM service authenticates users with email and password, the rate limiting and validation pipeline, device trust, token issuance, and the breach advisory system.

The login route authenticates an existing user by email and password. The request passes through three rate limiters (IP, email, and a composite key), a Zod schema validation with XSS detection, a database lookup, and an Argon2 password verification. On success, the service issues a refresh token and an access token, optionally re-baselines the device fingerprint, and checks the password against the Have I Been Pwned API as an advisory.


Route

MethodPathBody limit
POST/login1 KB

Middleware chain:

validateContentType('application/json'), express.json({ limit: '1kb' }), canary_id check, handleLogin

The canary_id cookie must be present. This cookie is set by the Bot Detector on first contact and serves as the device anchor for anomaly detection. If the cookie is missing, the route returns 400.

See Routes for how to mount the authentication router.


Request body

import { z } from 'zod'

const loginSchema = z.object({
  email: z.string().min(10).max(80).email(),     // XSS-sanitized via makeSafeString
  password: z.string().min(12).max(64),           // Must match password policy regex
}).required()

The password regex is the same one used during signup: at least one lowercase, one uppercase, one digit, one special character, no whitespace. The email field passes through makeSafeString for XSS detection.


The authentication pipeline

Content-Type check

The validateContentType middleware rejects requests that are not application/json with 403.

Rate limiting (IP)

An IP-based rate limiter allows 15 points per 24 hours. After exhaustion, the IP is blocked for 3 hours. A consecutive-violation cache escalates the block duration if the same IP is repeatedly rate-limited. See Rate Limiting for the guard() architecture.

Schema validation

The request body is validated against the Zod schema. If the makeSafeString check detects HTML tags in the email field, handleXSS bans the IP through the Bot Detector, marks the visitor as a bot, and returns 403 { banned: true }. If validation fails for other reasons, the controller returns 400 with Zod errors.

Rate limiting (email)

An email-keyed limiter allows 5 points per 24 hours, blocking for 5 hours on exhaustion. This prevents brute force against a specific account.

Rate limiting (composite key)

A union limiter keyed on ip + email combines a burst guard (1 point per second, blocks 30 minutes) with a slow guard (5 points per hour, blocks 30 minutes). This catches distributed attacks targeting a single account from rotating IPs.

Database lookup

The controller queries the users table for a row matching the email with active_user = 1. The query returns the visitor_id, password_hash, and id columns. If no active user is found, the controller returns 401 { error: 'Invalid email or password' }. The same generic message is returned for both "user not found" and "wrong password" to prevent user enumeration.

Password verification

The stored Argon2 hash is verified against the provided password using the same pepper from the configuration. The verifyPassword function returns a boolean. On failure, the controller returns 401 with the same generic message.


On successful authentication

After the password is verified, the controller performs several actions before sending the response.

Rate limiter reset

All three consecutive-violation caches (IP, email, composite) are cleared for the successful keys, and the union limiter counters for the composite key are reset in the database. This prevents a legitimate user from being locked out after a few typos followed by a correct login.

Device trust

If trustUserDeviceOnAuth is true in the configuration and the Bot Detector assigned a new visitor_id for this request (req.newVisitorId is set), the controller calls trustVisitor(). This function:

  1. Updates the user's visitor_id foreign key to point at the visitor row associated with the current canary_id
  2. Calls updateVisitors() from the Bot Detector to overwrite the visitor's fingerprint fields (geo, UA, device metadata) with the current request's data

The effect is that the anomaly detection engine compares future requests against the device the user most recently logged in from, rather than the original device. Without this option, users who switch devices accumulate canary-cookie mismatches and trigger MFA challenges.

The same device-trust logic runs in the OAuth flow.

Token issuance

Two tokens are generated:

TokenFunctionDescription
Refresh tokengenerateRefreshToken(refresh_ttl, userId)64 random bytes, hex encoded, SHA-256 hashed and stored in the refresh_tokens table
Access tokengenerateAccessToken({ id, visitor_id, jti })Signed JWT (HS512) with a random jti, the user's visitor_id, and any configured roles

See Refresh Tokens and Access Tokens for the full token lifecycle.

Two cookies are set alongside the JSON response:

CookieValuesameSitehttpOnlysecurepathdomain
iatDate.now() as stringstricttruetrue/
sessionRaw refresh tokenstricttruetrueFrom jwt.refresh_tokens.domain

The iat cookie tells the frontend when the access token was issued. The session cookie carries the raw refresh token, scoped to the configured domain.

Breach advisory

After tokens are issued and cookies are set, the controller checks the password against the Have I Been Pwned Passwords API using the same isPwned function described in the signup docs.

On login, a breached password does not block authentication. Instead, a breached field is included in the JSON response with an advisory message. The client can display this message to encourage the user to change their password.

{
  "breached": "Our system identified this password in 12,345 data breaches. Please consider changing your password."
}
The difference between login and signup is intentional. Blocking a breached password during signup prevents weak accounts from being created. Blocking during login would lock out existing users who have not changed their password yet. The advisory approach nudges users toward better passwords without denying access.

Response

Success (200)

{
  "ok": true,
  "receivedAt": "2025-01-15T10:30:00.000Z",
  "accessToken": "<signed JWT>",
  "banned": false,
  "accessIat": "1705312200000",
  "breached": "..."
}

The breached field is only present when the password is found in a data breach. The banned field is always false on success (a banned client never reaches this point).

Error responses

StatusCondition
400Missing canary_id cookie, empty body, schema validation failure
401User not found or wrong password
403HTML/XSS detected in input, or Content-Type mismatch
429Rate limit exceeded on any of the three limiters

Rate limiter reference

All login rate limiters are configurable under rate_limiters in the configuration object. The defaults are:

LimiterKeyPointsWindowBlock duration
IPreq.ip15 / 24 hours24 h3 h
Emailemail5 / 24 hours24 h5 h
Composite burstip + email1 / 1 second1 s30 min
Composite slowip + email5 / 1 hour1 h30 min

The guard() function also uses in-memory LRU caches (max 2000 entries, 24-hour TTL for IP and email, 1-hour TTL for the 429 composite cache) to track consecutive violations. See Rate Limiting for the escalation mechanics.


Configuration reference

Options that affect the login flow:

OptionLocationTypeDefaultDescription
trustUserDeviceOnAuthRoot configbooleanfalseWhether login re-baselines the visitor fingerprint to the current device
password.pepperRoot configstringServer-side Argon2 pepper (must match the value used during signup)
jwt.refresh_tokens.refresh_ttljwtnumberRefresh token lifetime in milliseconds
jwt.refresh_tokens.domainjwtstringCookie domain for the session cookie
jwt.access_tokens.expiresInjwtstring'15m'Access token JWT expiry (e.g. '15m', '1h')
jwt.jwt_secret_keyjwtstringSecret key for signing access tokens (HS512)

See Configuration for the full schema reference.

Logo