Skip to content

Security

Password handling

  • Hashing: argon2id with OWASP-recommended parameters (m=65536, t=3, p=4)
  • Passwords are never logged or returned in API responses
  • Password fields are redacted in all structured log output
  • Password policy is configurable via env — presets simple / standard / strong, plus per-rule overrides
  • Reuse prevention (opt-in): NUXT_PASSWORD_HISTORY_COUNT=N rejects a change or reset that reuses one of the user's last N passwords (the current one counts as the most recent). Only hashed retired passwords are kept, in a password_history table; they cascade-delete with the account and are excluded from the GDPR export. Disabled by default (0). See Configuration → Password reuse prevention.
  • Imported hashes (migration): the provisioning API accepts an existing bcrypt or PBKDF2 hash so a migrated user keeps their password. Such a credential is verified in its original format and silently re-hashed to argon2id on that user's next successful sign-in, so legacy formats drain out of the database rather than becoming permanent. Until then those accounts are only as strong as the source system's KDF — if the old one was weak, prefer a forced reset over an import. Parameters are range-checked at import in both directions. Upper bounds keep a stored hash from wedging the login path (a bcrypt cost is an exponent — $2b$31$ is a comparison that never returns). Lower bounds matter more: verification derives exactly as many bytes as the stored digest holds, so a digest shorter than 16 bytes would be a credential that roughly one password in 256 opens — an authentication bypass a truncating migration script could introduce by accident, and one that would outlive rotation of the token that planted it. Digest-length floors are enforced at verification time as well, so such a row fails closed; work-factor floors are import-only, because refusing to verify a weak-but-legitimate hash would lock the user out instead of protecting them. The bare $2$ bcrypt revision is rejected because it cannot be verified at all. See Migrating users in.
  • Going passwordless: a user with at least one passkey may remove their password (DELETE /api/me/password) to drop a phishable factor. Removal is confirmed with either the current password or a passkey assertion (POST /api/me/password-reauth → submit the assertion to DELETE /api/me/password) — proving the passkey works before the password is dropped. The challenge is bound to the session user and the asserted credential must be owned by them. The system always keeps at least one authentication factor — password removal is refused without a passkey, and removing the last passkey is refused without a password (password OR ≥1 passkey). Removing the password also clears that user's reuse-prevention history.

No third-party requests

The login pages load nothing from an external origin. Fonts are bundled with the app and served from your own host; there is no CDN, no analytics, and no webfont service in the path of signing in.

That is partly the single-instance design rule — the IdP must work air-gapped — and partly a privacy one: a CDN-hosted font discloses every visitor's IP address and the referring login URL to a third party before the user has consented to anything. For a login page in the EU that is a processing question you would rather not have to answer.

If you point NUXT_PUBLIC_THEME_LOGO_URL or NUXT_PUBLIC_THEME_BACKGROUND_IMAGE at a remote URL, you reintroduce exactly that: the browser fetches it from wherever you pointed. Host those assets yourself if it matters to you.

Address canonicalisation

Email addresses are stored trimmed and lowercased, and every lookup canonicalises its input the same way. This closes the one-mailbox-two-accounts path: a user cannot end up with a second account by capitalising their address, and a case-variant of a registered address cannot be used to register again or to slip past the duplicate check on admin-created and imported users.

The whole address is lowercased, local part included. RFC 5321 makes the local part case-sensitive in principle, but no mail provider honours that in practice. Subaddressing (user+tag@…) and dots are preserved — those are genuinely distinct mailboxes outside of Gmail's local policy.

Token handling

TokenStorageLifetimeInvalidation
Session (auth)httpOnly cookie15 min (7 days with "Remember me")Logout clears cookie; password change and "Sign out other devices" invalidate all other sessions via token_version. Sliding: auto-renewed after 50% of lifetime.
Email verificationSigned JWT (URL param)24 hoursSingle-use by design
Password resetSigned JWT (URL param)1 hourEmbeds current password hash — invalidated on password change
Account inviteSigned JWT (URL param)7 daysEmbeds invite_version — consumed by accepting it, which also voids any other outstanding invite for that account. Deliberately not token_version, so ending the user's sessions does not silently void an invite they have not opened yet.
WebAuthn challengeSigned JWT (body)5 minutesExpires naturally
OIDC access tokenBearer1 hourGrant revocation + explicit POST /oidc/revoke
OIDC refresh tokenOpaque7 daysOne-time use — rotated on every grant_type=refresh_token call

Session cookies: httpOnly=true, secure=true (production), sameSite=lax, path=/.

Users can end every other session from /account ("Sign out other devices") — it bumps token_version, the same mechanism a password change uses, and keeps the current session. Sessions are stateless JWTs, so there is intentionally no per-session list (no session store to enumerate).

On sign-out, relying parties that registered a backchannelLogoutUri receive a signed logout_token (OIDC Back-Channel Logout 1.0) so they can drop their own server-side sessions — see OIDC → Back-channel logout. Delivery is best-effort and the OP refuses to POST logout tokens to special-use (private/loopback) IP addresses (SSRF protection); use public HTTPS logout endpoints.

Two-factor authentication

Three second factors are supported, all completed after a correct password on the /verify-login step:

  • Authenticator app (TOTP, RFC 6238) — per-user opt-in, enrolled on /account via QR code. Works with no SMTP configured, so it's available even when email 2FA isn't. When a user has TOTP, it's required at login and is the default method ("TOTP wins"); other enrolled methods stay available via "use a different method".
  • Email code — a 6-digit code, enabled globally with NUXT_TWO_FACTOR_ENABLED (requires SMTP).
  • Passkey — a registered WebAuthn credential can satisfy 2FA.

TOTP specifics:

  • Secret encrypted at rest with AES-256-GCM using a dedicated NUXT_TOTP_ENCRYPTION_KEY (auto-generated to data/). It is not derived from NUXT_JWT_SECRET, so the incident-response "rotate the JWT secret to force-logout everyone" step does not wipe TOTP enrolments.
  • Replay-guarded — a code accepted once cannot be reused within its window (the last accepted step is recorded).
  • Backup codes — ten one-time recovery codes are generated at enrolment (shown once, stored as SHA-256 hashes), so a lost authenticator doesn't lock the user out even without SMTP. They can be regenerated; each is single-use.
  • Verification is rate-limited per-IP and per-user.

Mandatory two-factor

NUXT_REQUIRE_TWO_FACTOR=true forces every account to have a second factor. After a correct password, a user with no usable factor is corralled to /setup-2fa and must enrol TOTP or a passkey before they can use the account.

  • Hard, server-enforced gate. It is not just a UI redirect: a global middleware denies every /api/** request from an unsatisfied user with 403 two_factor_setup_required, except a small allowlist (read own profile, the TOTP/passkey enrolment endpoints, CSRF token, logout). OIDC completion is blocked for free — the interaction-finish route lives under /api/ — so a factorless user can't finish an authorization flow until they enrol.
  • Any factor satisfies it, including the emailed code. When NUXT_TWO_FACTOR_ENABLED + SMTP are configured, the email code is a universal factor and no forced enrolment ever happens. Forced enrolment therefore only triggers when email-2FA isn't available (no SMTP / toggle off) and the user has neither TOTP nor a passkey.
  • Who it applies to (NUXT_REQUIRE_TWO_FACTOR_SCOPE). Default all gates every account; set admins to gate only admin accounts (e.g. protect the admin surface without forcing enrolment on every end user). By default there is no admin exemption — admins are gated too, and the first admin enrols on the setup page (enrolment endpoints stay reachable, so there's no lockout).
  • New-account grace (NUXT_REQUIRE_TWO_FACTOR_GRACE_DAYS). Default 0 gates immediately. A positive value gives a freshly-registered account N days (from created_at) before the hard gate applies — so a brand-new user isn't slammed into the 2FA wall before finishing setup. It's a new-account grace: accounts older than N days are gated immediately, so enabling the policy never reopens a bypass window for existing users.
  • Enforced by recompute, not a session flag. Turning the policy on gates existing sessions on their very next request (no multi-day bypass window), and a user who removes their last factor is corralled again immediately.
  • Scope. REQUIRE_2FA guarantees a factor exists; it does not by itself re-challenge at every login. Challenge-on-every-login still comes from NUXT_TWO_FACTOR_ENABLED / TOTP — so a passkey-only user who password-logs-in isn't separately re-prompted. Pair the two toggles for both guarantees.

CSRF protection

Double-submit cookie pattern. Every POST/PUT/PATCH/DELETE request requires the X-CSRF-Token header, matched against the csrf_token cookie using a constant-time comparison. The token is rotated on login (and on every other server-side session transition: email verification, password reset, 2FA completion), so a stolen pre-auth token can't be reused post-auth.

The browser-side code reads the cookie directly from document.cookie (via a small getCsrfToken() composable) rather than through Nuxt's reactive useCookie. Reactive cookie state does not sync with Set-Cookie headers that arrive on $fetch responses, so after a server-side rotation the reactive value would lag one request behind — the composable side-steps that by always reading the live browser cookie.

OIDC endpoints (/oidc/**, /.well-known/**) are exempt from CSRF — they use PKCE + state to achieve the same guarantee.

Rate limiting

In-memory per-IP counters, resets on container restart — acceptable for a single-instance sidecar.

EndpointDefaultEnv override
Login10 / 15 minNUXT_RATE_LIMIT_LOGIN
Registration10 / 60 minNUXT_RATE_LIMIT_REGISTER
Password reset5 / 60 minNUXT_RATE_LIMIT_PASSWORD_RESET

Retry-After header sent when the limit is exceeded. Set NUXT_TRUST_PROXY=true when behind a reverse proxy so the X-Forwarded-For header is honoured — otherwise all requests look like they come from the proxy's IP.

Security headers

Set by middleware on every response:

  • Strict-Transport-Security: max-age=31536000 (only when NUXT_COOKIE_SECURE=true; intentionally bare — no includeSubDomains/preload)
  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • Referrer-Policy: strict-origin-when-cross-origin (keeps password-reset tokens out of the Referer header)

Hardening checklist

The app ships safe defaults; these are the steps you apply on top in production.

At the reverse proxy (see the deployment proxy examples):

  • [ ] Strengthen HSTS once every subdomain has TLS: Strict-Transport-Security: max-age=31536000; includeSubDomains; preload, then submit to hstspreload.org. The app deliberately sends the bare max-age so it can't lock out a not-yet-HTTPS subdomain.
  • [ ] Add the modern clickjacking control alongside the app's X-Frame-Options: Content-Security-Policy: frame-ancestors 'none' (or an explicit allowlist if you embed the IdP).
  • [ ] Strip identifying headers: remove Server and any X-Powered-By at the edge.
  • [ ] Remove NUXT_ADMIN_API_TOKENS_JSON once a migration is finished. That token can create sign-in-capable accounts; unset, the /api/provisioning/** routes return 404. While it is set, keep those routes off the public internet and prefer a fresh token per migration run (the id labels it in the audit log). It cannot grant admin — is_admin is not settable through the API by design.
  • [ ] Never expose internal endpoints publicly — keep /metrics and any admin tooling on the private network.

Encrypt the mail hop — verification, password-reset and invite links are bearer credentials, 2FA codes are one-time credentials, and if NUXT_SMTP_USER / NUXT_SMTP_PASS are set, SMTP AUTH PLAIN puts your relay password on the wire in recoverable base64. The admin Email & SMTP page reports what the connection actually negotiated, so this is verifiable rather than assumed:

  • [ ] Confirm the page shows Encrypted (STARTTLS) or Encrypted (implicit TLS). The default NUXT_SMTP_REQUIRE_TLS=auto upgrades whenever the relay offers it, but silently continues in cleartext when it doesn't.
  • [ ] Then set NUXT_SMTP_REQUIRE_TLS=true to make it mandatory — after confirming the relay supports it, since an unsupported upgrade becomes a failed send. This also turns on certificate validation, which auto deliberately skips (see Configuration → Transport security). Alternatively use implicit TLS: NUXT_SMTP_SECURE=true on port 465.
  • [ ] If you must set NUXT_SMTP_TLS_REJECT_UNAUTHORIZED=false for a self-signed relay, treat it as temporary: the hop resists passive capture but not an active man-in-the-middle. A real certificate is the fix.
  • [ ] Test emails from that page carry inert placeholder links and codes, so they are safe to send to shared inboxes — but the page is admin-only for a reason: it can send mail through your relay. Treat admin access accordingly.

Verify cookie flags in production — with NODE_ENV=production (or NUXT_COOKIE_SECURE=true), inspect Set-Cookie after a login:

  • [ ] auth_tokenHttpOnly; Secure; SameSite=Lax; Path=/
  • [ ] csrf_tokenSecure; SameSite=Strict (deliberately not HttpOnly — JS reads it for the double-submit check)
bash
curl -sI https://auth.example.com/api/csrf-token | grep -i set-cookie

Startup validation already refuses to boot with NUXT_COOKIE_SECURE=true over an http:// NUXT_APP_URL (see startup validation).

Audit registered OIDC clients (NUXT_OIDC_CLIENTS_JSON; review live at /admin/configurator):

  • [ ] redirectUris are exact and minimal — no wildcards, no stale/over-broad hosts.
  • [ ] public: true only for SPAs/native apps; server apps use confidential clients (public: false).
  • [ ] scopes are the minimum each client needs.
  • [ ] backchannelLogoutUri set for apps that hold server-side sessions.

Rotate keys and secrets on a schedule and on any suspicion — see Secret rotation for the per-secret steps and blast radius.

Stay on a current image:

  • [ ] Track releases and upgrade promptly — dependency security fixes ship as ordinary releases, and the CHANGELOG is the feed that names them. See Security upgrades for what an upgrade does and doesn't require.

OIDC

  • Authorization Code Flow only — no implicit, no hybrid
  • PKCE mandatory, S256 onlyplain rejected
  • Refresh tokens via offline_access scope — rotated on every use (one-time-use refresh)
  • Token revocation endpoint (POST /oidc/revoke) per RFC 7009
  • Token introspection endpoint (POST /oidc/introspect) per RFC 7662
  • JWKS derived from the RSA private key with RFC 7638 thumbprint for a stable kid
  • Role-gated sign-in (per client) — a client may set loginRequiresRole and/or loginRequiredRoles so only users holding a qualifying role can complete its authorization. The gate is enforced on every login-completion path (password, 2FA, passkey, and the email-verify / invite / password-reset resume flows), so there's no bypass. Denied users stay signed in to the IdP but receive no code. See OIDC → Role-gated sign-in.

App-managed roles write API

PUT /api/apps/roles lets a confidential client set its own app-roles for a user (server-to-server, HTTP Basic client auth). Its blast radius is deliberately narrow:

  • The write is scoped to the authenticated client's role group (its roleGroup, or its clientId) — taken from config, never the request body. A client can only write into its own group; clients sharing a roleGroup share the assignment by design, but no client can reach another group.
  • Within a group a client replaces only the roles it declares (siblings' roles are preserved), and the single/multiple cap is the group's strictest mode — so a multiple-mode client can't push more roles than a single-mode sibling would allow.
  • Roles are constrained to the client's env-defined roles set (no arbitrary strings; roleMode:"single" capped at one).
  • It writes only the role store. users.is_admin (the IdP admin flag) is a separate column that is never read or written here — an app-role named admin has no effect on IdP admin access.
  • Bad/missing credentials, or a public client (no secret), get a generic 401 invalid_client. Per-client rate-limited. Every write is audited (oidc.client.roles_updated).
  • Revealing a client secret (/api/admin/clients/:id/secret) is admin-only and audited (admin.client.secret_revealed).
  • An explicit per-client clientSecret (set in NUXT_OIDC_CLIENTS_JSON for reproducible deploys) is masked in GET /api/admin/config and the configurator export — the config-inspection surface never echoes it; use the audited reveal endpoint to see the real value.

Logging

  • Structured JSON via pino (pretty-printed in development)
  • Correlation IDs via X-Request-Id header (generated if not present)
  • Sensitive fields (password, token, secret, authorization, cookie) automatically redacted
  • Quiet paths (/_nuxt/, /__nuxt, /api/health) logged at debug level

Metrics endpoint

GET /metrics (Prometheus) is off by default — unset NUXT_METRICS_TOKEN returns 404, so there is no half-open "public metrics" state. When enabled, every scrape must present Authorization: Bearer <token>; the token is compared in constant time (over SHA-256 digests, so a wrong-length token can't trigger an error) and is never logged. Exposed series are aggregate counters only — no PII. Scrape from the private network; don't publish /metrics publicly even with the token.

Emitted series (all bounded-cardinality): schleuse_logins_total{result} (covers password and second-factor / passkey login outcomes), schleuse_registrations_total, schleuse_2fa_challenges_total, schleuse_rate_limit_hits_total{endpoint}, schleuse_emails_total{result}, schleuse_suspicious_logins_total, schleuse_oidc_tokens_issued_total{grant_type} (token-endpoint issuance by authorization_code / refresh_token), schleuse_force_logout_total, and http_request_duration_seconds{method,status_code,route} — the route label is the matched route template (or an allow-listed group), never a raw id-bearing path. Plus the default Node/process metrics.

Audit log

Every security-relevant event is persisted to the audit_log table and surfaced in the admin panel:

  • Registrations, logins (success / fail), logouts
  • Email verification, password changes, password reset flow (including password.reuse_blocked when a change/reset is rejected for reusing a recent password)
  • Account deletion
  • WebAuthn registrations + logins
  • Admin user updates + deletions
  • Data exports (data.exported for self-service, admin.user.exported for admin)
  • Suspicious logins (login.suspicious, when new-sign-in alerts are enabled)

Each entry has event, status, user_id, email, ip, user_agent, reason, request_id, created_at. See Admin.

Users can also see a self-scoped slice of their own activity on the account page (GET /api/me/activity) — their sign-ins, password changes, exports and any new-sign-in flags, with internal fields like request_id stripped. It only ever returns the calling user's own rows.

New-sign-in alerts

Set NUXT_SUSPICIOUS_LOGIN_ALERTS=true (requires SMTP) to email the user a "new sign-in detected" alert when a successful login comes from an IP or device the account hasn't been seen using before — across all login methods (password, email-code 2FA, passkey-2FA, passwordless passkey). The baseline is derived from the account's prior successful logins in the audit_log; each flagged login is recorded as a login.suspicious event and counted in schleuse_suspicious_logins_total.

The check is best-effort and never blocks or fails a login. A few properties worth knowing:

  • First login is never flagged, and enabling the flag does not retroactively alert existing users — device detection only arms once a User-Agent is on file.
  • IP accuracy depends on NUXT_TRUST_PROXY. Behind a reverse proxy without it, every login records the proxy's IP and the new-IP signal never fires; set NUXT_TRUST_PROXY=true so the client IP is read from X-Forwarded-For.
  • Device matching is version-independent. A tiny built-in (no-dependency) UA classifier reduces each User-Agent to a browser+OS family, so a browser auto-update (Chrome 120 → 121) is not flagged as a new device; a genuinely different browser or OS still is. Unrecognised UAs fall back to exact matching.
  • The alert shows a humanised device ("Chrome on macOS") instead of the raw User-Agent string (the raw string is used when nothing recognisable parses).
  • Alerts are coalesced. The email is rate-limited per user (bounded, in-memory) so IP churn — e.g. a mobile connection hopping addresses — can't spam the inbox; every flagged login is still recorded as login.suspicious regardless.
  • Optional step-up (NUXT_SUSPICIOUS_LOGIN_STEP_UP). Beyond notifying, you can require a second factor before a flagged login gets a session: set this (requires SMTP) and a suspicious login that would otherwise succeed is instead routed through the email-code challenge (reusing the 2FA flow — the user enters the code emailed to them). A suspicious login by a user who already has TOTP or a passkey is challenged by those; step-up only adds the email-code gate for password-only logins. Independent of the alert flag.
  • It does not do geo-location (that would require an external service or database, which the single-instance / no-external-dependency design rules out).

Data export (GDPR)

Users can download a copy of everything Schleuse holds about them from the account page (GET /api/me/export); admins can do the same for any user (GET /api/admin/users/:id/export). Both produce the same JSON file.

The export is built from an allowlist — each field is named explicitly, never a raw row dump — so it can never leak:

  • password hashes — only hasPassword and the credential timestamps are included.
  • OIDC token material — grants are projected down to clientId, scope and timestamps; no access/refresh/ID tokens are present.
  • JWT signing keys or anything under the data/ directory — never user data.

Every export is recorded in the audit log, so subject-access and admin exports are accountable. The endpoints are authenticated (and admin-guarded for the admin variant); keep them behind your reverse proxy like the rest of the app.

Self-service account deletion (erasure)

Users can also delete their account from the Danger zone at the bottom of the account page (DELETE /api/me, confirmed with the current password). Deletion cascades to credentials, passkeys, TOTP and role assignments, revokes the user's OIDC grants/tokens, clears the session, and fires the account.deleted webhook to configured clients. Admins can delete any account from the admin user page.

Deletion also anonymizes the account's audit-log entries so erasure is complete: the deleted user's rows keep only the event/status/timestamp skeleton — email, ip, user_agent, reason and request_id are nulled — and a single pseudonymized tombstone (account.deleted / admin.user.deleted, no plaintext email) is retained. So re-registering with the same address never surfaces the previous account's activity. The whole delete runs in one transaction. It also scrubs the deleted address from login.failed-style rows that referenced it with no user_id (keeping the event/IP so the security signal survives). For rows left by accounts deleted before this anonymisation existed, admins can run a one-off Anonymize orphaned rows sweep from the audit-log page.

Incident response

Concrete runbooks for the bad day. Rotation mechanics and blast radius live in Secret rotation; this is what to do, in order.

Leaked session secret (NUXT_JWT_SECRET / data/jwt-secret.txt)

  1. Set a new ≥32-char value (or delete the file to auto-generate) and restart.
  2. Every auth_token now fails signature verification → all users are logged out and must sign in again. CSRF and OIDC tokens are unaffected.

Compromised OIDC signing key (NUXT_JWT_PRIVATE_KEY / data/jwt-private-key.pem)

  1. Replace the PEM and restart → new JWKS kid. To avoid breaking in-flight tokens, use the no-downtime rotation (keep the old key as NUXT_JWT_PRIVATE_KEY_PREVIOUS for the transition) — but if the key is compromised, you may prefer to drop it immediately and accept that in-flight ID/JWT-access tokens fail verification.
  2. Re-issue every confidential client's secret — they're derived as sha256(NUXT_JWT_PRIVATE_KEY + clientId), so they all changed.

Force every user to log out

  • Cleanest: an admin clicks Sign out all users on the /admin dashboard (POST /api/admin/force-logout-all). It bumps every user's token_version in one bulk update, so all login sessions are invalidated immediately — no restart, signing secret untouched. The acting admin's current device stays signed in; already-issued OIDC tokens are unaffected. Logged as admin.force_logout_all.
  • Bigger hammer: rotate NUXT_JWT_SECRET (logs everyone out incl. the acting admin, and needs a restart — see above).
  • Scoped: the dashboard force-logout takes a scopeAll users or Non-admins only (the latter keeps admins working while everyone else is signed out).
  • Per user: an admin can Force logout a single account from its detail page (POST /api/admin/users/:id/force-logout); or the user changes their password / uses "Sign out other devices" on /account (all bump token_version).

Compromised individual account (block without destroying it)

  • Suspend the user from their admin detail page (POST /api/admin/users/:id/suspend). It's the reversible, non-destructive counterpart to delete: it ends their sessions (bumps token_version), blocks new password/passkey logins with a clear message, and revokes their OIDC tokens (so an already-issued refresh token can't keep minting access). The account's data is untouched.
  • Unsuspend when the incident is resolved (…/unsuspend) — the user logs in again, re-authenticating and re-consenting fresh. You can't suspend your own account. Both actions are audited (admin.user.suspended / .unsuspended).

Leaked confidential client secret Set (or bump) that client's secretVersion in NUXT_OIDC_CLIENTS_JSON and restart: its secret becomes sha256(NUXT_JWT_PRIVATE_KEY + clientId + secretVersion), so only that client rotates — every other client and the signing key are untouched. Recompute and hand the new secret to the RP (see Per-client secret rotation). Rotating NUXT_JWT_PRIVATE_KEY remains the bigger hammer (rotates all clients + the signing key).

Compromised user account An admin deletes the user at /admin/users/:id — this revokes their OIDC grants and fires back-channel logout to relying parties. There's no non-destructive "suspend" yet (tracked follow-up); short of deletion, have the user reset their password to invalidate their sessions.

Reporting vulnerabilities

For security issues, email the maintainer directly rather than opening a public GitLab issue. Fix, release, disclose — in that order.

A small but polished sidecar identity provider.