Login
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
| Method | Path | Body limit |
|---|---|---|
POST | /login | 1 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:
- Updates the user's
visitor_idforeign key to point at the visitor row associated with the currentcanary_id - 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:
| Token | Function | Description |
|---|---|---|
| Refresh token | generateRefreshToken(refresh_ttl, userId) | 64 random bytes, hex encoded, SHA-256 hashed and stored in the refresh_tokens table |
| Access token | generateAccessToken({ 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.
Cookie setting
Two cookies are set alongside the JSON response:
| Cookie | Value | sameSite | httpOnly | secure | path | domain |
|---|---|---|---|---|---|---|
iat | Date.now() as string | strict | true | true | / | — |
session | Raw refresh token | strict | true | true | — | From 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."
}
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
| Status | Condition |
|---|---|
400 | Missing canary_id cookie, empty body, schema validation failure |
401 | User not found or wrong password |
403 | HTML/XSS detected in input, or Content-Type mismatch |
429 | Rate 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:
| Limiter | Key | Points | Window | Block duration |
|---|---|---|---|---|
| IP | req.ip | 15 / 24 hours | 24 h | 3 h |
email | 5 / 24 hours | 24 h | 5 h | |
| Composite burst | ip + email | 1 / 1 second | 1 s | 30 min |
| Composite slow | ip + email | 5 / 1 hour | 1 h | 30 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:
| Option | Location | Type | Default | Description |
|---|---|---|---|---|
trustUserDeviceOnAuth | Root config | boolean | false | Whether login re-baselines the visitor fingerprint to the current device |
password.pepper | Root config | string | — | Server-side Argon2 pepper (must match the value used during signup) |
jwt.refresh_tokens.refresh_ttl | jwt | number | — | Refresh token lifetime in milliseconds |
jwt.refresh_tokens.domain | jwt | string | — | Cookie domain for the session cookie |
jwt.access_tokens.expiresIn | jwt | string | '15m' | Access token JWT expiry (e.g. '15m', '1h') |
jwt.jwt_secret_key | jwt | string | — | Secret key for signing access tokens (HS512) |
See Configuration for the full schema reference.