Skip to content

Security model

The full threat model - the attackers the design answers to, and a claim-to-test table - lives in docs/security-model.md. The short version:

Passwords. scrypt (N=16384, r=16, p=1), better-auth’s salt:key hex format. Each hash costs ~50 ms of CPU and ~32 MiB of scratch by design; the hasher bounds concurrency and pools buffers so a sign-in burst cannot exhaust memory.

Rate limiting is on by default (fail-closed), because the sign-in endpoint is expensive by construction. Disable it only when a gateway already throttles these paths; supply RateLimit.Storage when running more than one instance.

Sessions. 32-byte random tokens, HMAC-signed cookies with domain separation and __Secure- prefixes on HTTPS. Raw tokens never appear in listings.

One-time tokens (password reset, email verification, magic links, deletion, OAuth state) are stored as SHA-256 digests and consumed atomically - database read access yields no usable links.

OAuth. PKCE (S256); state pinned to the browser and the issuing provider, single-use, expiring. Automatic account linking requires a provider-verified email, a trusted provider, and a locally verified address on the account being linked into.

Passkeys. WebAuthn challenges are single-use and expire; the origin and relying-party id in the assertion are checked, signatures (ES256/RS256/Ed25519) are verified in-package, and the signature counter guards the cloned-authenticator case.

CSRF. State-changing requests are origin-checked against BaseURL

  • TrustedOrigins, falling back to Sec-Fetch-Site when no Origin/Referer is present. A wildcard entry (https://*.example.com, or scheme-agnostic *.example.com) matches strict subdomains only: the * must stand for at least one character, and an Origin equal to the pattern’s own text is never trusted.

Sign-in guards. Every sign-in path funnels through one gate, so two-factor and bans cannot be bypassed by choosing another method.

Secrets at rest. AES-256-GCM with keys derived from Secret. Encryption failure is an error, never a silent plaintext write; decryption failure is an error, never the ciphertext returned. Each ciphertext is bound to its model, record and field, so write access to the database cannot be turned into a read oracle.

Reporting. See SECURITY.md for the disclosure contact.