Security

Defense-in-depth security architecture of the IAM service, covering credential storage, token lifecycle, session binding, anomaly detection, network isolation, input sanitization, rate limiting, threat model, attack scenarios, and known limitations.

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

LayerComponentExposure
EdgeReverse ProxyPublic. Handles TLS termination, request body size limits, and upstream rate limiting.
ApplicationBFF server (auth-h3client)Semi-public. Receives browser requests through a proxy.
ServiceIAM service (@riavzon/auth)Private. Accessible only from the BFF on the internal Docker network.
DataMySQLPrivate. Accessible only from the IAM service.

Container hardening

The Docker container runs with a minimal security profile:

SettingValuePurpose
read_onlytrueFilesystem is read-only. The process cannot write to the container image. Logs and data use mounted volumes.
cap_dropALLAll Linux capabilities are dropped. The process cannot bind privileged ports, modify the kernel, or escalate privileges.
user10001:10001Runs as a non-root user and group.
security_optno-new-privileges:truePrevents the process from gaining new privileges through setuid binaries or other mechanisms.
pids_limit200Limits 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.

HMAC and mTLS are independent. You can enable either, both, or neither. HMAC alone is sufficient for most deployments where the Docker network is the trust boundary. Add mTLS when compliance requirements mandate encrypted internal traffic.

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.

ParameterDefaultPurpose
pepperRequiredSecret key mixed into every hash via Argon2's secret parameter
hashLength50Output hash length in bytes
timeCost4Number of Argon2 iterations. Higher values increase brute-force resistance.
memoryCost262144Memory 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.

Increasing 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.

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.


All cookies set by the service use the strictest available flags:

CookiePurposeFlags
sessionRaw refresh tokenhttpOnly, Secure, SameSite=Strict, Path=/, domain from config
iatToken issued-at timestamphttpOnly, Secure, SameSite=Strict, Path=/
canary_idDevice fingerprint bindinghttpOnly, 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 groupLimit
Signup, login, MFA, password reset1 KB
OAuth profile4 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:

FieldConstraints
Email10 to 80 characters, strict regex pattern (no double dots, valid DNS segments), domain MX validation
Password12 to 64 characters, must contain at least one lowercase, one uppercase, one digit, and one special character
Name2 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:

PatternCatches
/<\s*\/?\s*[A-Za-z][A-Za-z0-9-]*(?:\s+[^>]*?)?\s*>/iAny HTML tag
/on\w+\s*=/iInline event handlers (onclick=, onerror=)
/javascript\s*:/iJavaScript 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:

  1. Bans the IP with the maximum score (equal to banScore, default 100)
  2. Updates the banned IP record in the bot detector database
  3. 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:

  1. Runs the full anomaly detection pipeline against the incoming request
  2. Consumes the current refresh token atomically
  3. Revokes the old token
  4. Generates a fresh refresh token with a new row
  5. Generates a fresh access token with a new jti
  6. Sets new session and iat cookies 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.

#CheckTrigger conditionResponseRationale
1Token validityToken missing, revoked, or replayed (during rotation)Hard blockNo identity to challenge. Session is dead.
2Canary cookiecanary_id does not match stored valueMFANew device. User can prove identity.
3Idle detectionlast_seen older than 24 hoursMFAStale session. Re-verify before continuing.
4Session countActive sessions >= maxAllowedSessionsPerUserMFAToo many devices. User picks which to keep.
5Rapid creationMore than 3 valid tokens in 10 minutesHard blockAutomated behavior. Non-human actor.
6IP rangeIP outside the stored rangeMFANetwork change. User can confirm.
7Suspicion scoreBot detector score >= 25% of banScoreMFABot-like behavior accumulating. Early challenge.
8Proxy/hostingRequest from proxy or hosting without allow flagMFANew infrastructure. One-time MFA gate.
9Fingerprint loopAny geo or User-Agent field mismatchMFAEnvironment 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:

  1. Rate-limit the IP, the JTI, and the submitted code hash through uniLimiter, ipLimit, and usedJtiLimiter
  2. Validate and sanitize the submitted code through validateZodSchema
  3. Hash the code with SHA-256
  4. SELECT ... FOR UPDATE the matching mfa_codes row by jti, code_hash, not expired, not used
  5. Atomically DELETE the row (prevents replay)
  6. Block the submitted code hash in the limiter for 10 minutes
  7. In a single UPDATE users JOIN visitors, set users.last_mfa_at = UTC_TIMESTAMP(), users.visitor_id = currentVisitorId, visitors.proxy_allowed = 1, and visitors.hosting_allowed = 1
  8. Block the JTI in the limiter for 20 minutes
  9. Commit the transaction
  10. Call updateVisitors() in the bot-detector to overwrite the stored fingerprint with the current request's geo and User-Agent data
  11. Verify the bound refresh token, then revoke it (or revoke every refresh token for the user when revokeAllTokensOnSuccess is true, used by the password reset flow)
  12. Issue a new access token with a fresh randomUUID() JTI and a new refresh token row, then set the iat and session cookies

MFA verification is protected by rate limiters and consecutive failure caches that work together:

LayerKeyPurpose
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 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.

Always use a separate 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:

LayerPurpose
Union limiterCombines 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 limiterPer-IP rate limit applied independently of the union.
Identity limiterKeyed 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

EndpointLimiters
POST /loginUnion (burst + slow), IP, email, composite (IP + email)
POST /signupUnion IP (burst + slow), union composite (burst + slow), email
POST /auth/OAuth/:providerUnion IP (burst + slow), subject, composite (IP + subject)
POST /auth/user/refresh-sessionUnion access (burst + slow), union refresh (burst + slow), standalone refresh
POST /auth/forgot-passwordUnion (burst + slow), IP, email
MFA email sendingUnion (burst + slow), IP, user ID, global email cap
Link verificationUnion (burst + slow)
Temp POST routesUnion (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_id cookie on first contact
  • Resolves geolocation from the client IP via local MMDB databases
  • Parses the User-Agent header 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_score through 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:

CategoryFields
Geolocationcountry, countryCode, region, regionName, city, district, lat, lon, timezone, currency, isp, org, as_org
Devicedevice, deviceVendor, deviceModel
Browserbrowser, browserType, browserVersion, os
Networkproxy, 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:

  1. Point users.visitor_id at the visitor row associated with the current canary_id
  2. 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.

When 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:

LimiterKeyPurpose
IP burst + slowIP addressPrevents rapid OAuth attempts from the same IP
SubjectOAuth provider IDPrevents enumeration of valid OAuth subjects
CompositeIP + subjectFine-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' }
})
HeaderValueSource
Content-Security-PolicyHelmet defaults merged with frame-ancestors 'none'Custom + default
Strict-Transport-Securitymax-age=15552000; includeSubDomainsHelmet default
X-Content-Type-OptionsnosniffHelmet default
X-Frame-OptionsDENYCustom override
Cross-Origin-Embedder-Policyrequire-corpCustom override
Cross-Origin-Opener-Policysame-originHelmet default
Cross-Origin-Resource-Policysame-originHelmet default
Referrer-PolicyoriginCustom override
Origin-Agent-Cluster?1Helmet default
X-DNS-Prefetch-ControloffHelmet default
X-Download-OptionsnoopenHelmet default
X-Permitted-Cross-Domain-PoliciesnoneHelmet default
X-XSS-Protection0Helmet default (v4+)
Helmet v4 and later emit X-XSS-Protection: 0 on purpose. The legacy browser XSS filter has been removed from modern browsers, and when it was active it introduced cross-site leak bugs. Real XSS mitigation in this service comes from the sanitization pipeline and the strict CSP, not this header.

Structured logging

The service uses Pino for structured JSON logging. Every security event is logged with relevant context at the appropriate level:

EventLevelContext
Login successinfoUser ID, IP
Login failurewarnIP, reason
Anomaly detection triggerwarnCheck number, reason, IP, visitor ID
Rate limit hitwarnEndpoint, IP, key
XSS attemptwarnIP, payload summary, ban status
Token revocationinfoUser ID, reason
MFA code sentinfoUser ID
MFA code verifiedinfoUser 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.

The attacker must obtain four things to succeed: the refresh token, the 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:

  1. IP burst limiter catches rapid-fire attempts from a single IP
  2. IP slow limiter catches distributed attempts spread over time
  3. Email limiter prevents attacks targeting a single account
  4. 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:

LayerControl
ProxyShould enforces strict request body size limits at the reverse proxy level
Expressexpress.json() body size limits (1 KB for most routes)
SanitizerIrritationCount (default 50) caps the decoding loop iterations
SanitizermaxAllowedInputLength 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_count and valid flags. 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

LayerControls
EdgeCaddy with TLS termination, request body size limits, upstream rate limiting
TransportHMAC-SHA256 inter-service signatures, optional mTLS, replay detection via nonce cache
ContainerRead-only filesystem, all capabilities dropped, non-root user, PID limits
InputContent-type enforcement, body size limits, empty body rejection
StringsSix-stage XSS sanitization, Zod schema validation, automatic IP ban on HTML detection
PasswordsArgon2id with pepper, Have I Been Pwned breach check
TokensShort-lived JWTs with LRU cache binding, refresh tokens hashed in MySQL, single-use enforcement
SessionsCanary cookie binding, nine-check anomaly detection on every use, 30-day hard session limit
MFA7-digit OTP with 7-minute expiry, SHA-256 hashed storage, cascading revocation via foreign keys
Rate limitingPer-endpoint union limiters (burst + slow), IP/email/composite identity limiters, strike-based permanent blocking
Bot detectionIP reputation scoring, Tor/proxy/hosting detection, device fingerprinting, configurable ban thresholds
EmailDisposable email blocklist, DNS MX record validation, domain verification
HeadersHelmet (CSP, HSTS, X-Frame-Options, X-Content-Type-Options)
LoggingStructured JSON logging with Pino, security event tracking, no PII leakage in error responses
Secretsage-encrypted configuration, Docker secrets, pepper stored outside database
Logo