Security model¶
This page describes the threats regstack defends against and how. Each section calls out where regstack defers to the host (TLS, CSP headers, backups) so you know what is and isn’t your responsibility.
The summary: regstack tries to make the boring 80% of auth security correct by default, with no flags to turn off the defaults. Where there are tradeoffs, they’re documented here rather than buried in code.
Passwords¶
Hashing. Argon2id with library defaults via
pwdlib. regstack does not expose a standalone “needs rehash?” method — if the Argon2 parameters change later and you want existing hashes upgraded on next login, callpwdlib.PasswordHash.verify_and_update(password, hashed)directly inside a host-sideuser_logged_inhook (it returns both(verified, new_hash_or_None)in one pass).Length. Minimum 8, maximum 128 (UTF-8). Validated by the pydantic input model on every create / change endpoint.
Storage. Plaintext is never logged or returned. The
BaseUser.hashed_passwordfield is excluded from theUserPublicserialization model that the API returns.
JWT issuance and validation¶
regstack uses JWTs (RFC 7519) for authentication. A JWT is a signed, self-contained credential — the server doesn’t have to remember it to validate it. That’s why revocation needs explicit handling (see below).
Per-purpose signing keys. A single master
config.jwt_secret(≥32 chars) is fed through HMAC-SHA256 with a per-purpose label to derive a separate signing key for each token kind:session,password_reset,email_change,phone_setup,login_mfa. Compromise of one derived key does not compromise the master.iatis a float. RFC 7519 explicitly allows fractional seconds. We use them. This matters for the bulk-revoke comparison (see below) — without sub-second precision, a login completing in the same second as a password change would be wrongly revoked.expis enforced by regstack, using the injectedClock, rather than relying on pyjwt’s wall-clock check. This keepsFrozenClock-driven tests consistent and makes the cutoff comparison deterministic.purposeis required. Decode requires thepurposeclaim to match the expected purpose. Trying to use a session token where a password-reset token is expected fails at the JWT layer — well before any business logic.audis validated whenconfig.jwt_audienceis set.
Revocation: two complementary mechanisms¶
A JWT can’t be “logged out” the way a session cookie can — the server doesn’t store it. To make logout and password change actually invalidate tokens, regstack runs both checks on every authenticated request:
Per-token blacklist.
BlacklistRepostores{jti, exp}rows.POST /logoutinserts one; the auth dependency rejects any token whosejtiis present. Mongo gets free expiry via a TTL index onexp; SQL backends rely on read-side filtering plus the optionalpurge_expired()reaper.Bulk revocation.
User.tokens_invalidated_afteris a timestamp. The check ispayload.iat <= cutoff: tokens issued at or before the cutoff are revoked. A login completing microseconds after a password / email change hasiat > cutoff(floatiatmakes the comparison precise) and survives.
Bulk revoke fires on:
successful password reset
successful change-password
successful change-email confirm
admin-disabled user (
PATCH /admin/users/{id} {is_active: false})
Data-exposure trade-off¶
tokens_invalidated_after is intentionally included in the
UserPublic projection, and therefore appears in the response bodies
of every endpoint that returns the current user: GET /me,
GET /admin/users/{id}, and POST /confirm-email-change.
The intended consumer is a single-page app holding a regstack JWT.
The SPA can compare tokens_invalidated_after against the local
session JWT’s iat claim and detect that the token has been
invalidated by a password change or email change without paying for
a separate authenticated round-trip on every navigation. Without the
field in the response, the SPA would either have to make that extra
call or wait for the next genuinely-authenticated request to fail.
The consequence is that any session-token holder — including a stolen one — can observe the precise timestamp of the most recent security-affecting event on the account (password change, email change, admin disable). The timestamp is not by itself a credential and cannot be used to forge new tokens, but it is a signal an attacker would not otherwise have.
Hosts for whom that disclosure is unacceptable can strip the field in their own API wrapper layer — regstack does not currently expose a config knob to suppress it, since doing so would silently break the SPA-polling pattern the field exists for.
Account enumeration¶
Account enumeration is when an attacker can tell whether a given email is registered by observing how a public endpoint responds. It turns “guess passwords for a known account” into “harvest the customer list”.
regstack returns an identical response for “user exists” and “user does not” on the routes most useful for probing:
POST /forgot-password→ always 202 with the same body.POST /resend-verification→ always 202 with the same body.
POST /register does return 409 on a duplicate email, since the UX
benefit (“did you mean to log in?”) outweighs the enumeration concern
for a route that’s already rate-limited by the lockout subsystem and
visible to logged-out users only.
Login lockout¶
LoginAttemptRepostores one row per failed login{email, when, ip}.On Mongo the collection has a TTL index whose
expireAfterSecondsmatcheslogin_lockout_window_seconds— old failures reap themselves. SQL backends apply read-side window filtering.LockoutService.check(email)returnslocked=trueonce the count of failures-in-window exceedslogin_lockout_threshold.A locked login returns HTTP 429 with a
Retry-Afterheader before the password is verified, so a locked-out attacker can’t even tell whether their next guess was correct.Successful login calls
lockout.clear(email)to wipe accumulated failures.Disabled in tests via
rate_limit_disabled=True.
Per-route IP rate limits¶
Lockout defends each account against credential-stuffing. It does
nothing for an IP that hammers /forgot-password, /register, or
/verify against many accounts. For that, regstack supports
slowapi-backed per-route IP rate limits:
Opt in by installing the
rate_limitextra (pip install regstack[rate_limit]) or by passing a host-builtslowapi.LimitertoRegStack(rate_limiter=...). Hosts already using slowapi should pass their own Limiter so it shares state with the rest of the app.Set any of the
*_rate_limitconfig fields to a slowapi-syntax string. Each empty / unset field means “no limit on this route”:login_rate_limit = "30/minute;200/hour" register_rate_limit = "10/minute;50/hour" forgot_password_rate_limit = "5/minute;20/hour" reset_password_rate_limit = "5/minute;20/hour" verify_rate_limit = "10/minute;60/hour" resend_verification_rate_limit = "5/minute;30/hour" change_password_rate_limit = "5/minute;20/hour"
Hosts still own slowapi’s app-level wiring:
from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from slowapi.util import get_remote_address limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) rs = RegStack(config=cfg, rate_limiter=limiter) app.include_router(rs.router, prefix="/api/auth")
Failing closed: if
*_rate_limitis set but neither a Limiter nor the extra is available,regstack.routerraisesRuntimeErroron first access. We never silently disable a configured protection.
Email verification (durable, hashed token)¶
Random 32-byte URL-safe token, SHA-256 hashed in
pending_registrations.token_hash. The raw token only ever exists in the email body and the click URL — a database backup can’t be replayed.Mongo: TTL index on
expires_atreaps unused pending rows automatically. SQL backends rely on read-sideexpires_at > now()filtering (so stale rows are harmless) plus the optionalbackend.pending.purge_expired()reaper for disk hygiene.Re-issuing a code (
POST /resend-verification) atomically replaces the row, so the previous link silently stops working.Pending rows are deleted on successful verification.
Password reset¶
30-minute JWT (purpose
password_reset) carryingsub=user_id.The endpoint is anti-enumeration: 202 regardless of whether the email exists. The reset email is sent only if the address resolves to an active user.
On confirmation, regstack: (a) updates the password hash, (b) bumps
tokens_invalidated_afterto revoke every outstanding session, (c) clears the lockout for the user’s email so they aren’t still gated out.
Email change (re-auth + re-verify)¶
Requires the current password.
409 if the new email already belongs to another user.
1-hour JWT (purpose
email_change) carriessub=user_idand anew_emailcustom claim.The confirmation token is sent to the new address, not the old one — so a typo’d new email simply fails to deliver instead of silently locking the user out of their own account.
Confirm swaps the email atomically (DB-level unique constraint on
users.email), bumpstokens_invalidated_after, and clears the lockout for the previous address.
SMS 2FA¶
Codes are 6 digits, generated with
secrets.randbelow, hashed inmfa_codes.code_hashwith a TTL of 5 minutes.Per-user-per-kind unique so re-issuing a code overwrites the old one; prior SMS messages stop working.
Each row has an
attemptscounter; aftersms_code_max_attemptswrong guesses the row is deleted (forces a re-issue) and the response isLOCKED.Phone setup requires the current password before the code is sent.
Phone disable requires the current password (no SMS round trip — an attacker would need both the live session and the current password).
Phone numbers are validated as E.164 (the international phone number standard, e.g.
+15551234567).Login MFA: when
user.is_mfa_enabled and user.phone_number, the password-correct path issues a short-livedmfa_pendingJWT instead of a session token, sends an SMS, and requiresPOST /login/mfa-confirmto complete.
OAuth (Sign in with Google)¶
Opt-in subsystem behind enable_oauth and the oauth extra. Five
JSON endpoints plus an SSR token-handoff page. The full host-facing
guide is in OAuth; this section is the threat model.
Server-side PKCE. The
code_verifieris generated server-side and persisted on aoauth_statesrow; only its SHA-256code_challengeever travels through the browser. The token exchange POSTs the verifier directly from the regstack server to Google’s token endpoint, so a leaked browser-side state value alone can’t drive a token exchange.State row is the OAuth
stateparameter. Random 32-byte url-safe id; carriescode_verifier,nonce,redirect_to,mode(signinorlink), optionallinking_user_id. The callback looks the row up by id, rejects missing / expired rows with?error=bad_stateor?error=state_expired. Mongo gets free TTL viaexpireAfterSeconds; SQL backends rely on read-sideexpires_at > now()pluspurge_expired().ID token verification. Signature against Google’s JWKS (
PyJWKClientcached),issmatches Google,audmatches the configuredclient_id,exp > now,noncematches the value stashed on the state row. Any failure raisesOAuthIdTokenErrorand the callback redirects to the login page with?error=id_token_failed— the specific check that failed is logged but not echoed.Account-linking policy. Defaults to refuse. If a Google sign-in carries an email already owned by a regstack user, the callback returns
?error=email_in_useand the user has to sign in with their existing password before linking from/account/me. Auto-linking is available behindoauth.auto_link_verified_emails = true; even then, regstack requiresemail_verified=trueon the ID token. The threat auto-link accepts is email recycling at the provider — if someone later acquires the original Gmail address, they could sign in as the original regstack user. Hosts choosing auto-link do so eyes-open. Full writeup intasks/oauth-design.md§ 1.One-time token-handoff. After a successful callback, the fresh session JWT is stashed on the
oauth_states.result_tokenfield and the SPA exchanges its state-id for the token viaPOST /oauth/exchange. The exchange consumes the row atomically (read + delete in one transaction); a second exchange call with the same id returns 404. Tokens never appear in URLs longer than the callback redirect, no cookies are set.OAuth-issued sessions are normal session JWTs signed with the same
session-purpose key. Thetokens_invalidated_afterbulk- revoke applies — a password change or admin-disable kills any OAuth-issued session too.Open-redirect protection.
redirect_toon/startis validated same-origin againstconfig.base_url; a request with an off-site target returns 400.Identity-row uniqueness.
(provider, subject_id)is unique so two regstack users can’t share one external account; a second-user link attempt returns?error=identity_in_use.(user_id, provider)is also unique so re-linking the same provider to the same user returns?error=already_linkedrather than silently succeeding.OAuth-only users. A Google sign-up creates a user with
hashed_password=None. Login with a password against such an account returns the same generic 401 a wrong-password attempt gets — never reveal that an account exists but has no password set, so an attacker can’t enumerate which accounts to phish via OAuth.change-password/change-email/delete-accountreturn 400 with a pointer at the password-reset flow, which doubles as a “set initial password” path.Refuse to unlink the only auth method.
DELETE /oauth/{provider}/linkreturns 400 if the user has no password and only the one identity. Forces them to either set a password (via reset) or link another provider first.
CSP and the SSR layer¶
Content Security Policy (CSP) is a browser feature that restricts
what sources of scripts and styles a page can load. Inline <style>
and <script> blocks force you to either allow unsafe-inline
(which defeats most of CSP) or skip the header entirely. regstack
avoids that:
The bundled templates contain no inline
<style>blocks and nostyle="..."attributes. CSS is loaded only via<link>tags fromcore.css, the bundledtheme.css, and the optional hosttheme_css_url. Astyle-src 'self' <host-theme-domain>policy works withoutunsafe-inline.The bundled
regstack.jsis loaded via<script src defer>from the same static mount. A host CSP can add the static origin toscript-srcwithoutunsafe-inline.The SSR pages are stateless — they read endpoint URLs from
<body data-rs-api data-rs-ui>rather than baking them into the JS — so changing prefixes doesn’t require shipping new JS.Auth state is in
localStorageunderregstack.access_token. The pending-MFA token usessessionStorageso it doesn’t survive a tab close. No cookies are set, which sidesteps CSRF concerns at the cost of XSS being more impactful — hosts that need cookie-based sessions can swap the JS at the same data attributes.
What you still own as a host¶
TLS termination. regstack assumes its endpoints are reachable only over HTTPS in production.
Reverse-proxy header trust.
behind_proxy=Trueis informational; the host configures the actual middleware (e.g. Starlette’sProxyHeadersMiddleware).Content Security Policy headers. regstack’s SSR layer is CSP-friendly but the host emits the
Content-Security-Policyresponse header.Rate-limiting beyond the per-account login lockout. Per-route IP limits ship as the optional
rate_limitextra (see Per-route IP rate limits above). Host-level rate limiting (nginx, Cloudfront, …) is still the right place to push back broad attack traffic that isn’t worth letting hit Python at all.Backups, MongoDB user permissions, network-level isolation between the app and the database.
Reporting vulnerabilities¶
See SECURITY.md at the repository root.