Security
Password handling
- Hashing: argon2id with OWASP-recommended parameters (
m=65536,t=3,p=4) - Length cap: passwords are limited to 256 characters everywhere one crosses the API (set, change, verify). Argon2 input is attacker-chosen CPU otherwise; 256 chars lose no real entropy. An imported account with a longer password must use the reset flow once.
- No timing oracle: a login against an unknown email (or a passkey-only account) verifies the submitted password against a dummy argon2 hash, so the 401 costs the same as a wrong password — response time doesn't reveal whether an account exists. The forgot-password/resend endpoints likewise answer before any SMTP work happens.
- Passwords are never returned in API responses
- Password fields are redacted in all structured log output — with exactly one deliberate exception, below
- The bootstrap admin's generated password is written to the log, once. When
NUXT_BOOTSTRAP_ADMIN_EMAILis set withoutNUXT_BOOTSTRAP_ADMIN_PASSWORD, the service generates a 24-character password and logs it atwarnon the single start that creates the account — the log is the only channel an unattended install has. It is never logged again on subsequent starts, and a password you supply yourself is never logged at all. If your logs are shipped or retained off-host, setNUXT_BOOTSTRAP_ADMIN_PASSWORDexplicitly rather than letting one be generated. Either way, sign in, change the password, and remove the variables. See Admin panel → First-admin bootstrap. - Password policy is configurable via env — presets
simple/standard/strong, plus per-rule overrides. The configured rules are shown under every password field and tick green as each one is met, in the user's language. That preview covers the policy only: the server stays the authority, and a rejection it cannot predict — reuse of a previous password, whereNUXT_PASSWORD_HISTORY_COUNTis in force — still arrives on submit. - Reuse prevention (opt-in):
NUXT_PASSWORD_HISTORY_COUNT=Nrejects 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 apassword_historytable; 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 toDELETE /api/me/password) — proving the passkey works before the password is dropped. The challenge is purpose-bound (a login assertion can never double as an account-change confirmation), bound to the session user, and the asserted credential must be owned by them. The same proof-of-possession rule covers every factor change: replacing an active authenticator app requires a code from the current one, setting the first password on a passkey-only account requires a passkey assertion, and deleting the last passkey requires the password or an assertion. All of these confirmation checks share a per-user rate limit (5 failures / 15 min →429) and every failure lands in the audit log. 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.
A CAPTCHA from a vendor does the same, on /register and /forgot-password only: with turnstile, recaptcha or hcaptcha that page loads the vendor's script, and the vendor sees the visitor's IP address and browser signals. Sign-in and every other page stay first-party. With cap the widget, its solver and the verification all come from your own Cap instance, and the promise above holds.
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
| Token | Storage | Lifetime | Invalidation |
|---|---|---|---|
| Session (auth) | httpOnly cookie only (never echoed in a response body) | 15 min by default (7 days with "Remember me") — NUXT_SESSION_MAX_AGE_MINUTES / NUXT_SESSION_REMEMBER_ME_MAX_AGE_DAYS | Logout clears cookie; password change and "Sign out other devices" invalidate all other sessions via token_version. Carries a positive type: session claim — no other token kind can double as a session. Sliding: auto-renewed after 50% of lifetime, so the setting is an idle timeout. |
| Email verification | Signed JWT (URL param) | 24 hours | Single-use by design |
| Password reset | Signed JWT (URL param) | 1 hour | Embeds a SHA-256 fingerprint of the current password hash (never the hash itself) — invalidated on password change |
| Account invite | Signed JWT (URL param) | 7 days | Embeds 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 challenge | Signed JWT (body) | 5 minutes | Single-use: consumed (in-memory) on successful verification, so a captured challenge+assertion pair cannot be replayed — the authenticator counter alone is a no-op for synced passkeys that always report 0. Single-instance caveat: a restart forgets consumed ids, reopening at most a 5-minute one-replay window. |
| OIDC access token | Bearer | 1 hour | Grant revocation + explicit POST /oidc/revoke |
| OIDC refresh token | Opaque | 7 days | One-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
/accountvia 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 todata/). It is not derived fromNUXT_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.
Email login codes
The emailed 6-digit code gets the same treatment as TOTP:
- Per-user limits, independent of IP — 5 verification attempts per 15 minutes per account (guesses can't be spread across IPs), and 3 code sends per 15 minutes (no mail flooding). Both answer
429withRetry-After. - Stored hashed —
email_codesholds the SHA-256 of the code, so database read access never reads a live login code. - Offered methods are sealed at password time. Which second factors a login may complete with (
totp,email,passkey) is decided when the password is verified and embedded in the pending token; the send/verify endpoints enforce it. A TOTP account can therefore never be downgraded to mailbox access just because SMTP happens to be configured — withNUXT_TWO_FACTOR_ENABLED=false, the email code is refused as a second factor (the suspicious-login step-up is the one legitimate exception: it explicitly offers email).
Magic-link sign-in
With SMTP configured, the login page offers Email me a sign-in link (NUXT_MAGIC_LINK_ENABLED, on by default). What it proves is the mailbox — the same thing forgot-password already proves, since a reset link followed by a new password is account access too. So it does not lower the floor; it moves the front door to where the back door already was. Turn it off when a mailbox alone must not be enough.
- It is a first factor, never a second. After the link, the login takes the same path as after a password, minus the emailed code: an account with TOTP must still enter its code, and the mandatory-2FA gate still applies. The emailed code is removed from the offered methods (and the email step-up skipped), because a second mail to the same inbox proves nothing. With
NUXT_TWO_FACTOR_ENABLED=truethat makes a magic link one factor where password + emailed code is two — the trade-off you accept by leaving it on. - A GET never signs in. The link opens a page that names the account and waits for a button press; only that POST redeems it. Mail scanners that prefetch links (Outlook Safe Links and friends) can neither burn the link nor sign in, and an attacker mailing you a link to their account is shown as such. The token is removed from the address bar once the page has it. For convenience the page presses the button itself after a 5-second countdown (cancellable, and only while the tab is visible) — but only in the browser that requested the link. The request sets an httpOnly nonce cookie whose hash the link carries, and the server signs in without a click only when that cookie matches. A link opened anywhere else (another device, or one an attacker mailed you for their account — login CSRF) shows the button and nothing else; a mail scanner never holds the cookie.
- Single use, 15 minutes, revocable. The link is a signed token with a
jtithat is consumed on first use, and it carries the account's session version, so a password change, force-logout, suspension or 2FA reset voids every outstanding link. Consumedjtis are held in memory — a restart forgets them, so a link already used shortly before a restart could be used once more within its remaining lifetime. The same single-instance trade-off the passkey challenges make. - No mail to unverified or suspended accounts, and the same answer as for an unknown address. Unverified is the important one: an attacker who registered your address with their own password must not get you to activate that account by clicking a sign-in link.
- Throttled like forgot-password: the CAPTCHA, when configured, gates the request; per IP it uses
NUXT_RATE_LIMIT_PASSWORD_RESET's limit with its own counter; per account 3 links / 15 min, beyond which nothing is sent and the answer stays the same. Redeeming shares the login bucket. - Cross-device: opened in another browser, the link signs the user in there; an OIDC authorization started elsewhere is not resumed — the same ownership rule as verification and reset links.
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 with403 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). Defaultallgates every account; setadminsto 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). Default0gates immediately. A positive value gives a freshly-registered account N days (fromcreated_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_2FAguarantees a factor exists; it does not by itself re-challenge at every login. Challenge-on-every-login still comes fromNUXT_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.
| Endpoint | Default | Env override |
|---|---|---|
| Login | 10 / 15 min | NUXT_RATE_LIMIT_LOGIN |
| Registration | 10 / 60 min | NUXT_RATE_LIMIT_REGISTER |
| Password reset | 5 / 60 min | NUXT_RATE_LIMIT_PASSWORD_RESET |
| Magic-link request | 5 / 60 min | NUXT_RATE_LIMIT_PASSWORD_RESET (same limit, own counter) |
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.
Per-account counters, independent of IP. Password login additionally keeps a per-account counter (5 failures / 15 min): once tripped, even the correct password answers 429 until the window passes, so distributed guessing against one account is bounded no matter how many IPs it comes from. A successful login clears only that account's counter — shared per-IP buckets are never reset by anyone's success (an attacker used to zero them by logging into their own account) and decay only by window expiry. POST /api/webauthn/login/start shares the login bucket, so passkey/account enumeration is throttled too. Counters are in-memory and reset on restart (single-instance design); there is no durable lockout.
How the client IP is resolved. One shared resolver feeds rate limiting, audit-log rows and new-sign-in detection, so they can never disagree. With NUXT_TRUST_PROXY=false (the default) X-Forwarded-For is ignored entirely and the TCP socket address is used — a client cannot spoof it. With NUXT_TRUST_PROXY=true the rightmost X-Forwarded-For entry is used: that is the hop your proxy appended (or wrote), while the leftmost entries remain client-supplied and are never trusted. This assumes a single trusted proxy — the documented deployment shape; behind a chain of proxies the entry your outermost trusted hop records is what counts, so make sure the chain overwrites rather than blindly forwards the header. Values that are not syntactically valid IPs fall back to the socket address, and client-supplied X-Request-Id correlation IDs are only adopted when they are ≤64 characters of [A-Za-z0-9._-] — both caps exist so a hostile header can never make an audit-log insert fail.
A forwarded host cannot move the OIDC endpoints. Every URL the provider emits is pinned to NUXT_ISSUER before the request reaches it: X-Forwarded-Host is dropped and X-Forwarded-Proto is overwritten from the issuer's scheme. So a caller cannot make the discovery document advertise endpoints on a host they control, and cannot downgrade an https:// issuer to http://. This is a different header and a different mechanism from the X-Forwarded-For handling above, which NUXT_TRUST_PROXY still governs.
CAPTCHA
The per-IP limit stops an argon2 flood from one address; it does nothing against many addresses. An open instance with SMTP sends a verification or reset mail to every address a bot types in — mail your relay's reputation pays for. A CAPTCHA is the second line:
- Every unauthenticated mail-sending endpoint checks it, before any account lookup:
/api/auth/register,/api/webauthn/signup/start,/api/auth/password/forgotand/api/auth/resend-verification(which no page calls — gating it closes the way around the others). - Verification fails closed: provider down, timeout or an unreadable reply rejects the request and logs a
warn. A CAPTCHA that passes when its vendor is unreachable protects nothing. - Tokens are single-use; the page fetches a fresh one after every failed attempt.
- Rejections are logged (
captcha rejected, with the provider's reason), not written to the audit log — a bot run would fill it with rows nobody reads.
reCAPTCHA past its free quota lets everything through
Google's free tier is 10 000 checks a month. Beyond it, and without a billing account, Google's own documentation says the legacy verify endpoint "fails open": it answers success: true with a fixed score of 0.9. A v2 checkbox reply never carries a score, so Schleuse refuses any reCAPTCHA success that has one — sign-ups then fail instead of passing unchecked. Either way the CAPTCHA has stopped doing its job: enable billing, or pick Turnstile or Cap.
Security headers
Set by middleware on every response:
Strict-Transport-Security: max-age=31536000(only whenNUXT_COOKIE_SECURE=true; intentionally bare — noincludeSubDomains/preload)X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-origin(keeps password-reset tokens out of theRefererheader)Content-Security-Policy: frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'— a defense-in-depth floor; it deliberately sets noscript-src/style-src, so Nuxt's inline hydration and the theme (including an external logo URL) are unaffected. Under/oidc/*theform-actiondirective is dropped, because aform_postauthorization response is an auto-submitting form that legitimately POSTs cross-origin to the RP.Cache-Control: no-storeon every/api/response — authenticated JSON never lands in a browser or intermediary cache. (/oidc/*keeps the spec-mandated cache headers oidc-provider sets itself.)
The CSP sets no script-src, frame-src or connect-src, so a CAPTCHA widget needs no change to it. If your reverse proxy adds a stricter policy, allow the provider's origins on /register: Turnstile https://challenges.cloudflare.com (script, frame); reCAPTCHA https://www.google.com/recaptcha/, https://www.gstatic.com/recaptcha/ (script, frame); hCaptcha https://hcaptcha.com https://*.hcaptcha.com (script, frame, style, connect); Cap your Cap URL (script, connect) plus worker-src blob: and 'wasm-unsafe-eval'.
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 baremax-ageso 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
Serverand anyX-Powered-Byat the edge. - Remove
NUXT_ADMIN_API_TOKENS_JSONonce a migration is finished. That token can create sign-in-capable accounts; unset, the/api/provisioning/**routes return404. While it is set, keep those routes off the public internet and prefer a fresh token per migration run (theidlabels it in the audit log). It cannot grant admin —is_adminis not settable through the API by design. - Never expose internal endpoints publicly — keep
/metricsand 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).
NUXT_SMTP_REQUIRE_TLSdefaults totrue, so an unset variable already requires the upgrade and validates the relay's certificate — if mail is being delivered at all, the hop is encrypted. - Check you have not opted out.
NUXT_SMTP_REQUIRE_TLS=autoorfalseleaves the hop in the clear, and Schleuse warns about it at every boot for a non-loopback relay. If you setautobecause the relay's certificate is untrusted, preferNUXT_SMTP_TLS_REJECT_UNAUTHORIZED=falseinstead — it keeps STARTTLS mandatory and only stops verifying the certificate (see Configuration → Transport security). Alternatively use implicit TLS:NUXT_SMTP_SECURE=trueon port 465. - If you must set
NUXT_SMTP_TLS_REJECT_UNAUTHORIZED=falsefor 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_token→HttpOnly; Secure; SameSite=Lax; Path=/ -
csrf_token→Secure; SameSite=Strict(deliberately not HttpOnly — JS reads it for the double-submit check)
curl -sI https://auth.example.com/api/csrf-token | grep -i set-cookieStartup 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):
-
redirectUrisare exact and minimal — no wildcards, no stale/over-broad hosts. -
public: trueonly for SPAs/native apps; server apps use confidential clients (public: false). -
scopesare the minimum each client needs. -
backchannelLogoutUriset 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 only —
plainrejected. Enforced for confidential clients too, which is stricter than most IdPs and the posture OAuth 2.1 §7.5.2 recommends. A single confidential client can be waived withpkceExemptwhen a third-party component cannot send it; a public client never can. Every exemption is named in a startup warning, so it cannot become invisible. See OIDC → PKCE - Refresh tokens via
offline_accessscope — 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 - Service clients (
client_credentials) — a machine gets a token without an account. The grant is off unless a client declares it, and such a client must be confidential:public: trueplusclient_credentialsis a credentials grant with no credential — a public client authenticates withtoken_endpoint_auth_method: none, so the client id alone would mint tokens. Schleuse strips the grant from a public client at registration (its other flows keep working) and names it in a startup error. A service token reaches no user data —/oidc/userinforefuses it and it can carry norolesclaim — so its only authority is thescopeits resource server declares, narrowed further by the client'sallowedResources— mandatory for every JWT-AT client, since audiences are deny-by-default. Its secret is the whole credential: keep it out of images and rotate it withsecretVersion. Lifetime is 10 minutes by default (shorter per resource withaccessTokenTTL), and a JWT cannot be revoked before it expires — use opaque tokens plus introspection where instant revocation matters. See OIDC → Service clients - Role-gated sign-in (per client) — a client may set
loginRequiresRoleand/orloginRequiredRolesso 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. The gate honours only roles the client's role-group still declares inroles, so removing a role from a client's declaration revokes gated access as well as the claim — the declaration is the single revocation lever, and a lingering assignment row grants nothing. A gate naming a role nobody declares is reported at startup, because it locks every user out of that app. See OIDC → Role-gated sign-in. - Logout is confirmation-gated.
/oidc/session/endonly auto-submits when the request carries a validid_token_hintfor the signed-in account; anything else — a bare cross-site navigation included — renders a confirmation page, so no third-party page can silently destroy a user's IdP session (and trigger the back-channel logout fan-out). - Interaction binding — the email-driven resume flows (verify-email, password reset, invite) complete a pending OIDC authorization only in the browser that started it. When oidc-provider redirects a browser to
/login?interaction=…, Schleuse mints an httpOnly binding cookie proving that browser owns the interaction; the resume endpoints honour the uid only against that proof, and so does the login page's own completion call (/api/oidc/interaction/:uid/finish). A link opened on another device still verifies/resets/accepts normally and lands on/login— but nobody can bind your login to an authorization flow they started, whether by injecting their interaction uid into a password-reset request for your address or by phishing you into a one-click "continue as" on their uid.
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 itsclientId) — taken from config, never the request body. A client can only write into its own group; clients sharing aroleGroupshare 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 asingle-mode sibling would allow. - Roles are constrained to the client's env-defined
rolesset (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 namedadminhas 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 inNUXT_OIDC_CLIENTS_JSONfor reproducible deploys) is masked inGET /api/admin/configand the configurator export — the config-inspection surface never echoes it; use the audited reveal endpoint to see the real value.
Per-user app metadata store
GET/PATCH /api/apps/metadata (guide) is off unless NUXT_APP_METADATA_ENABLED=true, and the disabled check runs before the credentials — a switched-off deployment never becomes an oracle for whether a client secret is valid.
What the design guarantees, and what it does not:
- Tamper-proof by the user, not secret from them. No end-user-writable path exists: no
/api/me/metadata, no CORS on/api/**, nothing a user's session can write, and a public client (no secret) can never authenticate. An app can store an entitlement and know the user cannot forge it. But the values appear in that user's own GDPR export, because an IdP holding data about a person that the person cannot see is the thing it should least become. Metadata is therefore never emitted as an OIDC claim — an ID token travels the front channel and the user can decode it. - The operator is a second writer. An admin can correct a value from the user detail page; those edits are audited (
admin.user.metadata_updated, key names only, never values). The owning app is not the sole source of truth for its own namespace. - A leaked client secret reaches that client's namespace for any user — read and write — and, when a
metaGroupis shared, every group member's keys. Sharing ametaGroupmeans sharing write access; a peer can forge another app's flag. Do not share one unless you mean exactly that. - No shared API key exists. The store reuses the client's own
client_secret, so a write always has an identity. A single metadata key across all apps would let any app read and overwrite every other app's data with no attribution. - App writes are not audit-logged, deliberately: metadata may be written on every request, and one row per write would turn the audit log into an app's event stream. Writes are logged via pino with key names only — never values, which may hold personal data.
- Server-enforced caps (50 keys per user and namespace, 4096 bytes per value, 25 keys per request, a restrictive key charset) keep the store a key→value store rather than a general database behind the IdP. Admins are not exempt.
Outbound webhooks
- Payloads carry a user id and an email address. A webhook endpoint is a processor of personal data; prefer HTTPS targets and keep the list short.
- Sign them. A subscription without a
secretis unauthenticated: anyone who learns the URL can post a convincing "account deleted" to your systems. With one, every delivery carriesX-Schleuse-Signature: t=…,v1=…— HMAC-SHA256 over"<t>.<raw body>", with the timestamp there so a captured delivery cannot be replayed. login.failedis an amplifier. On a public instance a brute-force attempt becomes outbound traffic at the same rate. Subscribe deliberately.- Secrets are masked in
GET /api/admin/configand in every Configurator export, like OIDC client secrets. - The Send test button is admin-only, CSRF-protected, rate-limited per admin and audited (
admin.webhook.test_sent) — the same treatment as the SMTP test.
See Webhooks.
What an unauthenticated caller can learn
Measured against a release build with secrets deliberately planted in the configuration — none of them reaches an anonymous client:
/api/admin/**and/metricsanswer 401;/api/healthanswers{status:"ok"}and nothing else.- The sign-in page's payload carries theme values, the password rules and two UI flags. No issuer, no client ids, no client secrets, no SMTP host or credentials, no provisioning token.
/api/readyreportsstatus,database,oidcConfigured,registrationEnabled,magicLinkEnabledandpasswordRules— each of which the anonymous pages render anyway (the setup notice, the "Create one" link, the sign-in-link button, the live rule checklist), so withholding them would conceal nothing.
The running version is deliberately not in that list. It used to appear in /api/ready, in every page's public runtime config and in the footer of the sign-in card, which handed a stranger the one fact that turns into a list of published advisories to try. It now travels only on the authenticated /api/me (the signed-in layouts still show it) and as schleuse_build_info{version="…"} on the bearer-gated /metrics.
A readiness endpoint that required a credential would fail during the outage it exists to report, so /api/ready stays open — the fix was to make its body boring, not to lock it.
Logging
Structured JSON via pino (pretty-printed in development)
Correlation IDs via
X-Request-Idheader (generated if not present)Sensitive fields (
password,token,secret,authorization,cookie) automatically redactedQuiet paths (
/_nuxt/,/__nuxt,/api/health) logged atdebuglevelDatabase errors are scrubbed before they are logged. Knex rewrites an error's message to
<sql> - <driver message>with the bound parameters already interpolated, so a failinguserslookup would otherwise carry the email address and a failingpassword_credentialswrite the password hash. Single-quoted literals become'?'in both the message and the stack; table names, column names and the driver's reason survive, and double-quoted PostgreSQL constraint names are untouched.The framework's own raw error dump is suppressed in production. Nitro writes an unstructured
[request error] […]line with the whole error object to stderr, beside Schleuse's scrubbed line and outside its redaction. In production that line is gone for every request, whether it asks for JSON or for an HTML error page, leaving the structured, correlated, scrubbed record as the only error output. The HTTP response is unchanged: Nitro and Nuxt still produce it, error page included.In development the dump is deliberately kept: it is the only output with source-mapped stack frames, and a dev machine has no log shipping to leak into.
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_blockedwhen a change/reset is rejected for reusing a recent password) - Account deletion
- WebAuthn registrations + logins
- Admin user updates + deletions
- Data exports (
data.exportedfor self-service,admin.user.exportedfor admin) - Suspicious logins (
login.suspicious, when new-sign-in alerts are enabled)
Each entry has event, status, user_id, target_user_id, email, ip, user_agent, reason, request_id, created_at. See Admin.
user_id is the actor — for an admin action, the admin rather than the account acted on. target_user_id carries the subject when the two differ, which is what makes a per-user history complete: without it, filtering by a user's id returned their own logins while omitting every suspension and profile edit made about them. Neither column is a foreign key, deliberately: audit rows outlive the accounts they describe, and a cascade would delete the record of a deletion.
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; setNUXT_TRUST_PROXY=trueso the client IP is read fromX-Forwarded-For(rightmost entry — see Rate limiting for the exact rules). A client cannot spoof a baseline IP to suppress the alert: without the flag the header is ignored, with it only the proxy-recorded hop counts. - 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.suspiciousregardless. - 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
hasPasswordand the credential timestamps are included. - OIDC token material — grants are projected down to
clientId,scopeand 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. What that means differs by row, and the difference is deliberate:
| Row | What is removed | What stays |
|---|---|---|
The person's own actions (user_id) | email, ip, user_agent, reason, request_id | the event/status/timestamp skeleton |
An admin's action about them (target_user_id) | reason only | the acting admin's email, ip, user_agent, request_id |
login.failed-style rows with no user_id referencing the address | the email | the event and IP, so the security signal survives |
On a row where an admin acted, the email and IP are the admin's — nulling them would erase a still-existing account's accountability record while removing nothing about the erased user. reason is the one column that carries the subject: admin.user.updated writes Updated <email>: …. Both user_id and target_user_id are kept: opaque UUIDs that cannot be linked to a person once the account and its email are gone, and keeping them preserves the trail's shape.
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.
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. It matches an orphaned actor or an orphaned subject — a row can be orphaned on one side while the other is still live, which is the common case for an admin action.
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)
- Set a new ≥32-char value (or delete the file to auto-generate) and restart.
- Every
auth_tokennow 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)
- Replace the PEM and restart → new JWKS
kid. To avoid breaking in-flight tokens, use the no-downtime rotation (keep the old key asNUXT_JWT_PRIVATE_KEY_PREVIOUSfor 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. - Re-issue every confidential client's secret — they're derived as
sha256(NUXT_JWT_PRIVATE_KEY + clientId), so they all changed.
An admin is locked out (lost password or authenticator)
docker compose exec app schleuse user set-password <email>prints a new password and ends that user's sessions.- If the second factor is gone too, also run
schleuse user reset-2fa <email>. Both are audited (via CLI). See Console (CLI). The CLI opens no port: it needs shell access to the container, which already implies access to the database and keys.
Force every user to log out
- Cleanest: an admin clicks Sign out all users on the
/admindashboard (POST /api/admin/force-logout-all). It bumps every user'stoken_versionin 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 asadmin.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 scope — All 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 bumptoken_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 (bumpstoken_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.