Security
The IAM service is built on two principles: Zero Trust and Defense in Depth. Every layer of the authentication stack assumes the layers around it can fail and adds its own protection. A compromised password does not grant a session. A stolen token does not survive rotation. A replayed request does not pass anomaly detection. No single control stands alone.
This page is a reference for every security mechanism in the service. It covers what each control does, how the controls interact, what attack scenarios they defend against, and where the design has trade-offs or limitations.
If you are working with machine-to-machine credentials, see API token security for the dedicated security around API token verification, storage, management, and abuse controls.
Network isolation
The IAM service is designed to run behind a Backend for Frontend proxy inside a private Docker network. The service is never exposed to the public internet. Browsers communicate with the BFF (a Nuxt server, an h3 server, or any HTTP proxy). The BFF communicates with the IAM service over the internal network. The database sits on the same internal network, unreachable from outside.
Network layers
| Layer | Component | Exposure |
|---|---|---|
| Edge | Reverse Proxy | Public. Handles TLS termination, request body size limits, and upstream rate limiting. |
| Application | BFF server (auth-h3client) | Semi-public. Receives browser requests through a proxy. |
| Service | IAM service (@riavzon/auth) | Private. Accessible only from the BFF on the internal Docker network. |
| Data | MySQL | Private. Accessible only from the IAM service. |
Container hardening
The Docker container runs with a minimal security profile:
| Setting | Value | Purpose |
|---|---|---|
read_only | true | Filesystem is read-only. The process cannot write to the container image. Logs and data use mounted volumes. |
cap_drop | ALL | All Linux capabilities are dropped. The process cannot bind privileged ports, modify the kernel, or escalate privileges. |
user | 10001:10001 | Runs as a non-root user and group. |
security_opt | no-new-privileges:true | Prevents the process from gaining new privileges through setuid binaries or other mechanisms. |
pids_limit | 200 | Limits the number of processes the container can spawn. Prevents fork bomb attacks. |
Service-to-service authentication
Trust between the BFF and the IAM service is not implicit, even on a private network. The service supports two independent verification layers:
HMAC signatures. Every request from the BFF carries four headers: a client identifier, a millisecond timestamp, a unique request ID, and an HMAC-SHA256 signature computed from a shared secret. The IAM service recomputes the signature and compares it using crypto.timingSafeEqual. This prevents unauthorized containers on the same network from calling IAM endpoints. See HMAC Authentication for the full verification flow, replay detection, and clock-skew tolerance.
Mutual TLS (mTLS). The auth-h3client supports configuration for a custom HTTPS agent. When configured, both the BFF and the IAM service present certificates, and each side verifies the other.
Credential storage
Password hashing
All passwords are hashed with Argon2id using the argon2 library. Argon2id is the OWASP-recommended variant for password storage. It combines resistance to GPU-based parallel attacks (from Argon2d) with resistance to side-channel attacks (from Argon2i).
A pepper is mixed into every hash as a secret key. The pepper is a long random hex string stored outside the database (in a secrets manager, environment variable, or age-encrypted configuration file). If the database is leaked, an attacker cannot crack the hashes without also obtaining the pepper. Rotating the pepper invalidates all existing hashes, forcing a password reset for every user.
| Parameter | Default | Purpose |
|---|---|---|
pepper | Required | Secret key mixed into every hash via Argon2's secret parameter |
hashLength | 50 | Output hash length in bytes |
timeCost | 4 | Number of Argon2 iterations. Higher values increase brute-force resistance. |
memoryCost | 262144 | Memory usage in KiB (256 MiB). Higher values make GPU attacks more expensive. |
The verification function (verifyPassword) catches all errors internally and returns false on any failure. No error details are leaked to the caller or the client.
timeCost and memoryCost makes hashing more expensive for attackers but also slows down your login and signup endpoints. Benchmark these values against your target response time before deploying. See Configuration for all password hashing options.Refresh token hashing
Refresh tokens are 64-byte random strings generated with crypto.randomBytes(64) and hex-encoded to 128 characters. Before any database operation, the raw token is hashed with SHA-256. The database stores only the hash. The raw token is sent to the client exactly once, inside an httpOnly cookie, and never stored in plaintext on the server.
The toDigestHex utility checks whether the input is already a valid SHA-256 hex string before hashing. If the input passes a Zod z.hash("sha256") check, it passes through unchanged. If not, SHA-256 is applied. After hashing, ensureSha256Hex runs a strict regex validation (/^[a-f0-9]{64}$/i) and throws if the result is malformed. This double gate prevents truncated or corrupted hashes from reaching the database.
See Refresh Tokens for the full token lifecycle, storage schema, and rotation mechanics.
MFA code hashing
One-time MFA codes are 7-digit numeric strings generated with crypto.randomInt(1_000_000, 9_999_999). The raw code is sent to the user's email. A SHA-256 hash of the code is stored in the mfa_codes table with a 7-minute expiry. The raw code never touches the database.
When the user submits the code, the service hashes the submission and compares the hash against the stored value inside a database transaction. Expired or used codes are deleted atomically. The token foreign key on mfa_codes cascades to refresh_tokens, so revoking a user's refresh token automatically invalidates any pending MFA code tied to that session.
See MFA for the full OTP generation, delivery, verification, and rate limiting flow.
Token lifecycle
The service uses a two-token architecture with a canary_id cookie for session binding.
Access tokens
Access tokens are short-lived JWTs signed with a configurable algorithm (default HMAC). The service caches every generated token in an LRU cache at creation time. During verification, the token must pass both cryptographic signature validation and exist in the cache. This design means you can revoke any valid access token instantly by deleting its cache entry, without waiting for natural expiry.
The payload includes visitor (the device binding), roles, sub (user ID), jti (unique token ID generated with crypto.randomUUID()), iat, and exp. Additional custom claims can be merged via jwt.access_tokens.payload in the configuration.
See Access Tokens for cache management and verification semantics.
Refresh tokens
Refresh tokens are opaque random strings stored hashed in MySQL. Each token row carries a usage_count column that is the core of the single-use enforcement system. A fresh token starts at 0, gets atomically incremented to 1 on consumption, and any second use triggers a kill switch that revokes every active session for that user.
The atomic consumption happens in a single UPDATE inside a database transaction: it increments usage_count by one, but only if the token exists, is valid, has never been consumed, and has not expired. All four conditions must pass for the row to be affected. If no row is affected, the function investigates why.
See Refresh Tokens for the full schema, consumption flow, rotation helpers, and the reuse detection walkthrough.
Canary cookie
The canary_id cookie is a 64-character hex string generated by the Bot Detector middleware on first contact. It is set as an httpOnly, Secure, SameSite=Lax cookie with a 90-day TTL. The value is used as the primary key in the visitors table, which stores geo, device, and browser fingerprint data for the device.
Every refresh token operation compares the incoming canary_id against the stored value. A mismatch means the request is arriving from a different browser or device than the one that established the session, and adaptive MFA is triggered. See Anomaly Detection check 2 for the exact comparison logic.
Cookie security
All cookies set by the service use the strictest available flags:
| Cookie | Purpose | Flags |
|---|---|---|
session | Raw refresh token | httpOnly, Secure, SameSite=Strict, Path=/, domain from config |
iat | Token issued-at timestamp | httpOnly, Secure, SameSite=Strict, Path=/ |
canary_id | Device fingerprint binding | httpOnly, Secure, SameSite=Lax, 90-day TTL |
The httpOnly flag prevents JavaScript from reading the cookie, eliminating XSS-based token theft. The SameSite=Strict policy on the session cookie prevents CSRF attacks, the browser will not include the cookie on any cross-origin request, whether initiated by a form submission, a redirect, or an XMLHttpRequest.
The makeCookie utility also supports the __Host- and __Secure- cookie prefixes. When a cookie name starts with __Host-, the utility forces Secure=true, Path=/, and removes the domain attribute. When the name starts with __Secure-, it forces Secure=true.
Input validation and sanitization
Content-type enforcement
Every POST route requires Content-Type: application/json. The validateContentType middleware rejects requests with any other content type with HTTP 403. This prevents multipart, URL-encoded, or XML payloads from bypassing JSON-level input validation.
Request body limits
All routes set an explicit body size limit via express.json():
| Route group | Limit |
|---|---|
| Signup, login, MFA, password reset | 1 KB |
| OAuth profile | 4 KB |
Requests with an empty body are rejected with HTTP 403 before JSON parsing begins.
Zod schema validation
Every user-supplied field passes through a Zod schema before any business logic runs. The schemas enforce type, length, and pattern constraints:
| Field | Constraints |
|---|---|
| 10 to 80 characters, strict regex pattern (no double dots, valid DNS segments), domain MX validation | |
| Password | 12 to 64 characters, must contain at least one lowercase, one uppercase, one digit, and one special character |
| Name | 2 to 72 characters, letters only, 1 to 4 space-separated names |
When validation fails, the service returns HTTP 400 with field-level error messages. The error response never reveals which specific condition failed in a way that would help an attacker enumerate valid inputs.
XSS sanitization pipeline
All user-supplied strings pass through a stage of sanitization pipeline before they reach the database. The pipeline is designed to catch layered encoding attacks, where an attacker nests multiple layers of URI encoding, HTML entities, or Unicode substitutions to bypass single-pass filters.
Length guard
Before any processing, the sanitizer rejects input longer than htmlSanitizer.maxAllowedInputLength (default 50000). Oversized input throws rather than entering the loop. This is the hard cap on CPU cost for a single call.
Unicode normalization
The input is normalized to NFKC, which collapses visually similar characters to their canonical form Zero-width characters, soft hyphens, byte-order marks, and bidirectional override characters are stripped in the same pass. Halfwidth and
fullwidth ASCII characters (U+FF01 through U+FF5E) are transliterated back to standard ASCII. This defeats payloads that hide tags inside fullwidth substitutions such as \uFF1C for <.
Strict URI decode
decodeURIComponent is called once inside a try/catch. If the call throws (malformed percent-encoding like %ZZ), the input is rejected immediately and returned as an empty string with htmlFound: true. Legitimate input does not contain malformed URI sequences, so rejecting early keeps broken data out of the loop.
Iterative decoding loop
The function alternates between decodeURIComponent and HTML entity decoding (via the he library) in a loop. Each pass strips one layer of encoding. The loop runs until the output stabilizes (no change between iterations) or the IrritationCount limit (default 50) is reached. This catches payloads like %253Cscript%253E, which decodes to %3Cscript%3E on the first pass and <script> on the second.
If the loop exceeds IrritationCount without stabilizing, the input is rejected entirely and returned as an empty string with the HTML detection flag set.
Residual cleanup
After the loop, zero-width characters are stripped again (the decoders may have reintroduced them) and any whitespace inside the bodies of surviving tag-like substrings is removed so that <scr\tipt> cannot slip past the tag regex.
Pattern detection
The decoded string is tested against three patterns:
| Pattern | Catches |
|---|---|
/<\s*\/?\s*[A-Za-z][A-Za-z0-9-]*(?:\s+[^>]*?)?\s*>/i | Any HTML tag |
/on\w+\s*=/i | Inline event handlers (onclick=, onerror=) |
/javascript\s*:/i | JavaScript protocol URIs |
Any match sets htmlFound: true. That flag propagates to validateZodSchema, which calls handleXSS and bans the IP.
sanitize-html pass
The string passes through sanitize-html with a strict configuration:
allowedTags: [],allowedAttributes: {},allowedIframeHostnames: [],allowedSchemes: [],allowProtocolRelative: false,nestingLimit: 10,nonTextTags: ['script', 'style', 'noscript', 'iframe', 'svg'].
The textFilter re-runs the tag regex on text nodes, and onOpenTag records any tag name and attribute set that the sanitizer had to strip. If the string shrinks during this pass, htmlFound is set to true even if pattern detection did not trigger.
Entity encoding
The final output is entity-encoded: &, <, >, ", ', backtick, and ${ are replaced with their entity or escaped equivalents. The backtick and template-literal escapes prevent injection into JavaScript template strings. The result is trimmed.
See XSS Protection for the full pipeline details, Zod integration, and the automatic IP ban behavior.
Automatic IP banning on XSS detection
When the Zod validation layer detects an 'HTML found' error (emitted by the sanitization pipeline), the validateZodSchema function calls handleXSS. This function imports the Bot Detector ban utilities and immediately:
- Bans the IP with the maximum score (equal to
banScore, default 100) - Updates the banned IP record in the bot detector database
- Marks the visitor as a bot
The response returns HTTP 403 with { banned: true }. The attacker's IP is blocked from all subsequent requests.
Session management
Token rotation on every use
The service enforces refresh token rotation on every access. When the BFF calls POST /auth/user/refresh-session, the controller:
- Runs the full anomaly detection pipeline against the incoming request
- Consumes the current refresh token atomically
- Revokes the old token
- Generates a fresh refresh token with a new row
- Generates a fresh access token with a new
jti - Sets new
sessionandiatcookies on the response
The old token is dead after step 2. The new token is the only valid credential for future requests. If the old token is presented a second time , the consumption function detects usage_count > 0, revokes all tokens for that user, and returns { valid: false, reason: 'Token already used' }.
Reuse detection and the kill switch
The usage_count column is the backbone of the reuse detection system. Here is how it handles the two possible stolen-token scenarios:
Legitimate user rotates first
The user's BFF rotates the token. usage_count goes from 0 to 1. The attacker presents the now-consumed token. The consumption function finds usage_count > 0, revokes all tokens for that user, and returns an error. The attacker's session is dead. The user is also logged out (they must re-authenticate), but the attacker gained nothing.
Attacker rotates first
The attacker uses the stolen token before the user does. usage_count goes from 0 to 1. The attacker receives new tokens. When the user's BFF later presents the now-consumed token, the same reuse detection fires: all tokens for that user are revoked, including the ones the attacker just received. The attacker's session is immediately invalidated.
In parallel, the anomaly detection pipeline notices the mismatch in canary_id, IP address, or device fingerprint on the attacker's request and triggers adaptive MFA. The attacker must complete a 7-digit OTP challenge sent to the user's email to continue. Without access to the user's inbox, the attacker cannot use the rotated token.
See Refresh Tokens: Reuse Detection for the database-level walkthrough of this flow.
Session lifetime enforcement
Even with continuous rotation, sessions have a hard maximum lifetime. The MAX_SESSION_LIFE configuration defaults to 30 days and is checked during every rotation. The service compares Date.now() against session_started_at, which is set once at token creation and carried forward to each new token on rotation. If the session has exceeded the maximum lifetime, the token is revoked, cookies are cleared, and the user must log in again.
Concurrent session limits
The maxAllowedSessionsPerUser configuration (default 5) limits how many active refresh tokens a user can have simultaneously. When the anomaly detection pipeline counts valid tokens for a user and the count equals or exceeds this limit, adaptive MFA is triggered. This prevents an attacker from silently opening many sessions under a single account.
Anomaly detection
Every time a refresh token is used, the strangeThings() function runs nine sequential checks against the request context. The checks query the refresh_tokens, users, and visitors tables in a single join, then compare the stored fingerprint against the incoming request. The first check that fails short-circuits the rest.
Each failure carries a reqMFA flag. When reqMFA is true, the caller sends an OTP email challenge and the user can recover by proving their identity. When reqMFA is false, the request is a hard block: the token is revoked and the user must log in again from scratch.
| # | Check | Trigger condition | Response | Rationale |
|---|---|---|---|---|
| 1 | Token validity | Token missing, revoked, or replayed (during rotation) | Hard block | No identity to challenge. Session is dead. |
| 2 | Canary cookie | canary_id does not match stored value | MFA | New device. User can prove identity. |
| 3 | Idle detection | last_seen older than 24 hours | MFA | Stale session. Re-verify before continuing. |
| 4 | Session count | Active sessions >= maxAllowedSessionsPerUser | MFA | Too many devices. User picks which to keep. |
| 5 | Rapid creation | More than 3 valid tokens in 10 minutes | Hard block | Automated behavior. Non-human actor. |
| 6 | IP range | IP outside the stored range | MFA | Network change. User can confirm. |
| 7 | Suspicion score | Bot detector score >= 25% of banScore | MFA | Bot-like behavior accumulating. Early challenge. |
| 8 | Proxy/hosting | Request from proxy or hosting without allow flag | MFA | New infrastructure. One-time MFA gate. |
| 9 | Fingerprint loop | Any geo or User-Agent field mismatch | MFA | Environment changed. User can re-verify. |
After a successful MFA challenge, the user's last_mfa_at timestamp is updated. For the duration of byPassAnomaliesFor (default 3 hours), check 4 (session count) is skipped entirely. All other checks still run regardless of when MFA was last completed.
See Anomaly Detection for the complete function signature, data lookup, per-check details, and the MFA bypass window mechanics.
Multi-factor authentication
The service ships with two MFA mechanisms:
Adaptive MFA is triggered automatically when anomaly detection returns reqMFA: true. The user does not initiate it. The service detects the anomaly, generates a 7-digit OTP, hashes it with SHA-256, stores the hash in the mfa_codes table with a 7-minute expiry, and sends the code to the user's email inside a magic link.
Custom MFA is triggered by your application for sensitive user actions (password change, fund transfer, account deletion). You call the provided initiation route, and the service handles code generation, email delivery, and verification identically to adaptive MFA.
Both mechanisms share the same verification function, which runs inside a database transaction:
- Rate-limit the IP, the JTI, and the submitted code hash through
uniLimiter,ipLimit, andusedJtiLimiter - Validate and sanitize the submitted code through
validateZodSchema - Hash the code with SHA-256
SELECT ... FOR UPDATEthe matchingmfa_codesrow byjti,code_hash, not expired, not used- Atomically
DELETEthe row (prevents replay) - Block the submitted code hash in the limiter for 10 minutes
- In a single
UPDATE users JOIN visitors, setusers.last_mfa_at = UTC_TIMESTAMP(),users.visitor_id = currentVisitorId,visitors.proxy_allowed = 1, andvisitors.hosting_allowed = 1 - Block the JTI in the limiter for 20 minutes
- Commit the transaction
- Call
updateVisitors()in the bot-detector to overwrite the stored fingerprint with the current request's geo and User-Agent data - Verify the bound refresh token, then revoke it (or revoke every refresh token for the user when
revokeAllTokensOnSuccessis true, used by the password reset flow) - Issue a new access token with a fresh
randomUUID()JTI and a new refresh token row, then set theiatandsessioncookies
MFA verification is protected by rate limiters and consecutive failure caches that work together:
| Layer | Key | Purpose |
|---|---|---|
| Union limiter | {IP}_{JTI} | Limits attempts per IP and verification session |
| Code hash limiter | {code_hash} | Blocks a specific code after successful use (prevents replay) |
| JTI limiter | {JTI} | Prevents reuse of a verified JTI |
| Consecutive cache (hash) | {code_hash} | Tracks repeated submissions of the same wrong code (10 min) |
| Consecutive cache (IP) | {IP} | Progressive slowdown on repeated failures from same IP (10 min) |
| Consecutive cache (JTI) | {JTI} | Tracks failures per verification session (20 min) |
See MFA for the full lifecycle, OTP generation, email template, and verification flow.
Magic link security
Magic links are short-lived, single-use JWTs embedded as URL query parameters in emails. They are used for adaptive MFA, password reset, email update, and custom MFA flows.
Each MFA link is signed with HMAC-SHA512 using a dedicated secret key
(magic_links.jwt_secret_key), separate from the main JWT secret. The
JWT ID (jti) is built as crypto.randomUUID() concatenated with
crypto.randomBytes(64).toString('hex'), producing a 36-character UUID
followed by 128 hex characters. The extra entropy makes brute-force
enumeration of active JTIs infeasible.
Each magic link URL also carries a random query parameter:
crypto.randomBytes(128).toString('hex'), a 256-character hex string.
The server keeps only the SHA-256 digest of that value inside the signed
JWT payload as randomHashed. On verification, the middleware hashes the
incoming random, loads randomHashed from the decoded JWT, and
compares the two digests using timing-safe equality. The raw value is
never persisted, and the comparison is constant-time.
Links are cached in a server-side LRU store. When a link is consumed, the cache entry is removed, which means a verified link cannot be replayed even if the JWT itself has not expired. Cache misses (expired or evicted entries) fail verification immediately without attempting JWT decoding.
jwt_secret_key for magic links. Never reuse the access-token or refresh-token secret. This limits the blast radius if one secret is compromised.Rate limiting
The service uses rate-limiter-flexible for all rate limiting. Every sensitive endpoint has its own named limiter group. The limiters store state in MySQL with in-memory mirrors for fast rejections.
Layered design
A typical endpoint has three limiter layers:
| Layer | Purpose |
|---|---|
| Union limiter | Combines a burst limiter (low points, short window) and a slow limiter (higher points, longer window). Both are consumed on every attempt. If either rejects, the request is blocked. |
| IP limiter | Per-IP rate limit applied independently of the union. |
| Identity limiter | Keyed on email, OAuth subject, token hash, or user ID depending on the endpoint. Prevents attacks targeting a single identity. |
Strike system
On top of the limiter layer, a consecutive failure cache tracks strikes per key in an LRU cache. When a key accumulates enough strikes (the maxBans threshold), the guard function permanently blocks the key via the limiter's block() method. Blocked keys are rejected immediately on subsequent requests without consuming limiter points.
Endpoint coverage
| Endpoint | Limiters |
|---|---|
POST /login | Union (burst + slow), IP, email, composite (IP + email) |
POST /signup | Union IP (burst + slow), union composite (burst + slow), email |
POST /auth/OAuth/:provider | Union IP (burst + slow), subject, composite (IP + subject) |
POST /auth/user/refresh-session | Union access (burst + slow), union refresh (burst + slow), standalone refresh |
POST /auth/forgot-password | Union (burst + slow), IP, email |
| MFA email sending | Union (burst + slow), IP, user ID, global email cap |
| Link verification | Union (burst + slow) |
| Temp POST routes | Union (burst + slow), IP |
See Rate Limiting for the full limiter configuration, the guard and unionLimiter API, and the MySQL storage pool setup.
Bot detection
The Bot Detector middleware runs before any authentication route. It scores every request based on IP reputation, header consistency, geolocation anomalies, and device fingerprint deviations. The score accumulates in the visitors table and feeds directly into anomaly detection (check 7).
The bot detector:
- Generates the
canary_idcookie on first contact - Resolves geolocation from the client IP via local MMDB databases
- Parses the
User-Agentheader into structured device, browser, and OS fields - Upserts the visitor record with all fingerprint data
- Tracks Tor exit nodes and known hosting/proxy ranges
- Maintains a
suspicious_activity_scorethrough the checker pipeline
The MFA threshold is deliberately low: anomaly check 7 triggers at 25% of banScore (default 25 out of 100). This means MFA challenges start well before the visitor reaches the ban threshold, giving legitimate users a chance to prove themselves while slowing down automated attacks.
See Bot Detector Configuration for the full scoring model, checker options, and ban behavior.
Device fingerprinting
The getFingerPrint middleware builds a composite device fingerprint from the incoming request. It is a server-side fingerprint: no canvas hashing, no WebGL probing, no browser APIs. The data comes entirely from request headers and IP metadata.
The fingerprint combines:
| Category | Fields |
|---|---|
| Geolocation | country, countryCode, region, regionName, city, district, lat, lon, timezone, currency, isp, org, as_org |
| Device | device, deviceVendor, deviceModel |
| Browser | browser, browserType, browserVersion, os |
| Network | proxy, hosting, ipAddress |
The anomaly detection fingerprint loop (check 9) compares each incoming field against the stored visitor record. The comparison skips any field where either side is null, undefined, or the string 'unknown'. A single mismatch on a populated field triggers MFA.
See Fingerprinting for the full field reference and how fingerprint data flows through login, rotation, and MFA.
Trusted devices on login
The trustUserDeviceOnAuth configuration option controls whether the login controller re-baselines the user's visitor record. When set to true, a successful login calls trustVisitor() to:
- Point
users.visitor_idat the visitor row associated with the currentcanary_id - Overwrite the visitor fingerprint with the current request's geo and User-Agent data
This means the stored fingerprint always reflects the device the user most recently logged in from. Without this option, the fingerprint stays fixed to the initial device, which causes false positives when users legitimately switch devices.
trustUserDeviceOnAuth is true, a successful login marks the device as trusted and skips adaptive MFA when the canary_id changes on the next session. This reduces friction but weakens the anomaly detection layer. Set to false for high-security deployments.Signup security
The signup flow runs a specific sequence of checks before creating an account:
Content-type and rate limiting
The request must have Content-Type: application/json. IP-based burst and slow limiters run first, followed by composite key (IP + email) limiters.
Input validation
The Zod schema validates name, email, password, confirmedPassword, rememberUser, and termsConsent. The termsConsent field must be the literal string "on". If any field contains HTML, the visitor is banned immediately.
Email domain validation
The service performs a DNS MX record lookup on the email domain. If the domain has no mail server, the signup is rejected.
Disposable email detection
The email domain is checked against the Shield Base disposable email blocklist (stored in a local LMDB database). Disposable domains are rejected.
Password breach check
The password is checked against the Have I Been Pwned database using k-anonymity (only the first 5 characters of the SHA-1 hash are sent to the API). During signup, a breached password blocks account creation. The user must choose a different password.
Password hashing and account creation
The password is hashed with Argon2id + pepper, and the user record is created in a database transaction. If the email already exists, the service returns HTTP 409.
See Signup for the full flow and response format.
Login security
The login flow shares several checks with signup but has some differences:
Rate limiting
IP, email, and composite key (IP + email) rate limiters run before any database query.
Input validation
The Zod schema validates email and password with the same pattern and length constraints as signup.
User lookup
The service queries for a user with the submitted email. If no user is found, the response is { ok: false, error: 'Invalid email or password' }. The same error message is returned for invalid passwords to prevent user enumeration.
Password verification
Argon2id verification runs with the stored hash and the pepper. The verification is internally constant-time (Argon2 handles this). On failure, the same generic error message is returned.
Password breach check
The password is checked against Have I Been Pwned. During login, a breached password does not block the login. Instead, the response includes a breached warning message advising the user to change their password.
Device trust and token issuance
If trustUserDeviceOnAuth is enabled, the device fingerprint is re-baselined. A new refresh token and access token are generated. Rate limiter state is reset for the compositeKey on success.
See Login for the full flow and response format.
OAuth security
OAuth social login providers are validated with Zod schemas. Each provider definition maps an incoming OAuth profile to the internal user schema. Providers can be defined with either a full custom Zod schema for complete control, or a field-type map shorthand that builds the schema automatically from type tokens ('string', 'email', 'safeString', 'url', 'boolean', 'number', 'int', each optionally suffixed with ? for optional fields).
OAuth requests pass through three rate limiters:
| Limiter | Key | Purpose |
|---|---|---|
| IP burst + slow | IP address | Prevents rapid OAuth attempts from the same IP |
| Subject | OAuth provider ID | Prevents enumeration of valid OAuth subjects |
| Composite | IP + subject | Fine-grained control combining both dimensions |
If the OAuth profile passes schema validation and the email is not already registered with a password-based account, the user is created. Duplicate emails return HTTP 409.
See OAuth for the full provider definition and validation flow.
Security headers
The service registers Helmet in service.ts with the following explicit overrides:
helmet({
crossOriginEmbedderPolicy: true,
xFrameOptions: { action: 'deny' },
contentSecurityPolicy: {
directives: { 'frame-ancestors': ["'none'"] }
},
referrerPolicy: { policy: 'origin' }
})
| Header | Value | Source |
|---|---|---|
Content-Security-Policy | Helmet defaults merged with frame-ancestors 'none' | Custom + default |
Strict-Transport-Security | max-age=15552000; includeSubDomains | Helmet default |
X-Content-Type-Options | nosniff | Helmet default |
X-Frame-Options | DENY | Custom override |
Cross-Origin-Embedder-Policy | require-corp | Custom override |
Cross-Origin-Opener-Policy | same-origin | Helmet default |
Cross-Origin-Resource-Policy | same-origin | Helmet default |
Referrer-Policy | origin | Custom override |
Origin-Agent-Cluster | ?1 | Helmet default |
X-DNS-Prefetch-Control | off | Helmet default |
X-Download-Options | noopen | Helmet default |
X-Permitted-Cross-Domain-Policies | none | Helmet default |
X-XSS-Protection | 0 | Helmet default (v4+) |
Structured logging
The service uses Pino for structured JSON logging. Every security event is logged with relevant context at the appropriate level:
| Event | Level | Context |
|---|---|---|
| Login success | info | User ID, IP |
| Login failure | warn | IP, reason |
| Anomaly detection trigger | warn | Check number, reason, IP, visitor ID |
| Rate limit hit | warn | Endpoint, IP, key |
| XSS attempt | warn | IP, payload summary, ban status |
| Token revocation | info | User ID, reason |
| MFA code sent | info | User ID |
| MFA code verified | info | User ID |
Error responses never include stack traces, internal error messages, or database details. All server-side errors return a generic message to the client.
Configuration encryption
Sensitive configuration (database credentials, JWT secrets, HMAC shared secrets, pepper, email API keys) is stored in an age-encrypted file (config.json.age). The decryption key is mounted as a Docker secret. The decrypt.sh script decrypts the file at container startup, the service loads it, and then deletes it from disk.
Attack scenarios
This section walks through specific attack scenarios and how the service's security layers respond.
Stolen refresh token
The most plausible attack vector. An attacker obtains a valid refresh token through phishing.
If the user rotates first: The user's BFF rotates the token before the attacker can use it. usage_count goes from 0 to 1. The attacker presents the now-consumed token. consumeAndVerifyRefreshToken detects usage_count > 0 and revokes all tokens for that user. The attacker's attempt fails. The user is logged out and must re-authenticate, but the session is safe.
If the attacker rotates first: The attacker uses the token before the user. The anomaly detection pipeline runs on the attacker's request. Even if the attacker somehow has the canary_id cookie, they still face check 6, check 8, and check 9 (the fingerprint consistency loop). Any single mismatch on a populated field triggers adaptive MFA. The attacker must then complete a 7-digit OTP challenge sent to the user's email. Meanwhile, when the user's BFF presents the now-consumed token, reuse detection fires and revokes all sessions, including the attacker's newly issued tokens.
canary_id cookie, a matching device fingerprint (same IP range, geo, browser, OS, device), and access to the user's email. Phishing can realistically obtain the token and the cookie. Spoofing the full fingerprint requires a residential proxy in the same IP range and an identical User-Agent. Completing the MFA challenge requires compromising the user's inbox. Each layer is an independent barrier.Replay attack during rotation
An attacker captures a rotation request (token + cookies) and replays it. The first use increments usage_count to 1. The replay finds usage_count > 0. The strangeThings function detects the reuse and blocks the request. If the full consumption path runs, all sessions for the user are revoked.
Cross-site scripting (XSS)
An attacker makes a payload containing obfuscated HTML or JavaScript and submits it through a form field (signup name, email, etc.). The input passes through the six-stage sanitization pipeline. The iterative decoding loop strips nested encodings. The pattern detection catches tags, event handlers, and JavaScript URIs. The Zod validation layer detects the 'HTML found' error, calls handleXSS, and bans the attacker's IP with the maximum bot detector score. The response is HTTP 403 with { banned: true }.
See XSS Protection for the full pipeline.
Credential stuffing
An attacker uses a list of leaked email/password pairs to attempt mass logins. The login endpoint is protected by four independent rate limiters:
- IP burst limiter catches rapid-fire attempts from a single IP
- IP slow limiter catches distributed attempts spread over time
- Email limiter prevents attacks targeting a single account
- Composite key limiter (IP + email) catches the specific pairing
After consecutive failures, the strike system permanently blocks the key. The attacker receives HTTP 429.
Denial-of-service via token reuse
An attacker obtains a valid refresh token and uses a residential proxy in the victim's IP range, a spoofed User-Agent that matches the victim's browser, OS, and device fields, and the victim's canary_id cookie. With all fingerprint fields aligned, the attacker's request passes the anomaly detection pipeline cleanly. They send a single request.
The service rotates the token for the attacker. When the legitimate user makes their next request, the system detects reuse and triggers the kill switch: all tokens for the user are revoked. The user is logged out.
The attacker cannot hijack the session because the rotated token triggers MFA on the next anomaly, and the kill switch fires on reuse. The attack is also self-limiting: once the legitimate user logs in again with a fresh password authentication from a trusted device, the service issues a new refresh token and re-baselines the visitor fingerprint. The attacker's previously stolen token, canary_id, and fingerprint data are now invalid. To repeat the attack, the attacker would need to steal the new credentials all over again.
The attack can persist only as long as the attacker holds valid, current credentials. A single clean login by the user breaks the cycle. This is a deliberate trade-off: the system accepts the risk of temporary user lockout to maintain zero tolerance for stolen tokens.
Device compromise (malware/RAT)
If an attacker compromises the victim's physical endpoint via malware or a Remote Access Trojan, they operate as the user. They inherit the correct IP, cookies, and device fingerprint. The anomaly detector passes the request. The kill switch does not trigger.
The service is phishing-resistant but not malware-proof. Defense against endpoint compromise requires hardware-bound FIDO2/WebAuthn factors, which are not currently enforced in the default configuration.
CPU exhaustion via sanitization
An attacker crafts a payload containing deeply nested encoded entities (for example, megabytes of %252525...). The iterative decoding loop in the sanitization pipeline processes each layer, potentially blocking the Node.js event loop.
Mitigation is layered:
| Layer | Control |
|---|---|
| Proxy | Should enforces strict request body size limits at the reverse proxy level |
| Express | express.json() body size limits (1 KB for most routes) |
| Sanitizer | IrritationCount (default 50) caps the decoding loop iterations |
| Sanitizer | maxAllowedInputLength rejects oversized inputs before sanitization begins |
Database compromise
If an attacker gains read access to the database:
- Passwords are hashed with Argon2id + pepper. Without the pepper, the hashes are not crackable.
- Refresh tokens are stored as SHA-256 hashes with
usage_countandvalidflags. The attacker cannot reuse them without the unhashed token, which exists only in the user's cookie. - MFA codes are stored as SHA-256 hashes with a 7-minute expiry. The attacker cannot reverse the hash to obtain the code.
- Magic link JWTs are signed with a secret stored in the encrypted configuration file, not in the database.
Limitations
No hardware-bound authentication
The service does not enforce FIDO2/WebAuthn or hardware security keys. All authentication factors (password, OTP code, magic link) can be phished or intercepted by malware on the user's device. Adding WebAuthn as an optional second factor is a planned enhancement.
False positives from network changes
The default anomaly detection configuration is tuned for high security. Legitimate users who travel, switch networks, or use VPNs may trigger false-positive anomaly detections, resulting in MFA challenges or session termination. The byPassAnomaliesFor window (default 3 hours) mitigates this after a successful MFA challenge, but frequent IP changes within a single session can still cause repeated challenges.
Single-threaded sanitization
The XSS sanitization loop runs synchronously on the Node.js event loop. While the IrritationCount and maxAllowedInputLength limits prevent worst-case scenarios, a crafted payload near the limits can still cause brief latency spikes. The upstream reverse proxy is the primary defense against volumetric attacks targeting this surface.
No per-session encryption at rest
Refresh tokens are hashed (not encrypted) in the database. SHA-256 is a one-way function and cannot be reversed, but it is not a keyed operation. If an attacker obtains both the database and a valid raw token (for example, from a browser memory dump), they can verify which row the token maps to. Full encryption at rest with a key management service would add a layer but is not currently implemented.
Email-only MFA
The service currently supports email-based OTP as the only MFA channel. This means MFA security is bounded by the security of the user's email account. If the email account is compromised, the attacker can complete MFA challenges. Support for TOTP (RFC 6238) and push-based authentication are potential future additions.
Summary
| Layer | Controls |
|---|---|
| Edge | Caddy with TLS termination, request body size limits, upstream rate limiting |
| Transport | HMAC-SHA256 inter-service signatures, optional mTLS, replay detection via nonce cache |
| Container | Read-only filesystem, all capabilities dropped, non-root user, PID limits |
| Input | Content-type enforcement, body size limits, empty body rejection |
| Strings | Six-stage XSS sanitization, Zod schema validation, automatic IP ban on HTML detection |
| Passwords | Argon2id with pepper, Have I Been Pwned breach check |
| Tokens | Short-lived JWTs with LRU cache binding, refresh tokens hashed in MySQL, single-use enforcement |
| Sessions | Canary cookie binding, nine-check anomaly detection on every use, 30-day hard session limit |
| MFA | 7-digit OTP with 7-minute expiry, SHA-256 hashed storage, cascading revocation via foreign keys |
| Rate limiting | Per-endpoint union limiters (burst + slow), IP/email/composite identity limiters, strike-based permanent blocking |
| Bot detection | IP reputation scoring, Tor/proxy/hosting detection, device fingerprinting, configurable ban thresholds |
| Disposable email blocklist, DNS MX record validation, domain verification | |
| Headers | Helmet (CSP, HSTS, X-Frame-Options, X-Content-Type-Options) |
| Logging | Structured JSON logging with Pino, security event tracking, no PII leakage in error responses |
| Secrets | age-encrypted configuration, Docker secrets, pepper stored outside database |