This document describes how Stohr protects authentication, sessions, files, and shared links — and where you (the operator) are on the hook.
- Passwords hashed with Argon2id via
@atlas/auth'shash/verify. Plaintext passwords are never logged or stored. The login path runs an Argon2 verify against a fixed decoy hash on a missing-user lookup so response timing doesn't leak account existence. - JWTs are signed with
SECRET. In production the API refuses to start ifSECRETis the default value or shorter than 32 characters. Tokens carry ajticlaim, live for 7 days, and are server-side revocable via thesessionstable. - Personal access tokens (PATs) for SDKs / mobile apps are 32-byte random values prefixed
stohr_pat_. Stored as SHA-256 hashes; revealed only at creation time. - WebAuthn passkeys (FIDO2) — see below.
- Password reset via signed email link — see below.
Every successful login/signup writes a row to sessions keyed by the JWT's jti. The auth guard checks this table on every authed request — revoking a session blocks the token immediately, even though it's still cryptographically valid.
Sessions are revoked automatically on:
- password change (all sessions other than current)
- password reset (all sessions, including current)
- MFA enable / disable (all sessions other than current)
- explicit user revocation via Settings → Security or
DELETE /me/sessions/:jti
- RFC 6238 TOTP, HMAC-SHA1, 30-second window, ±1 window tolerance
- 160-bit symmetric secret per user, generated server-side, shown once via QR
- 10 backup codes minted at enable time (Argon2-hashed, single-use, regenerable)
- Login flow: password →
mfa_required: true+ 5-minute MFA challenge JWT → second call with code or backup code
Built on @simplewebauthn/server. Users can register one or more passkeys at Settings → Security and use them as either a primary authenticator (passwordless login) or a second factor.
- Registration:
POST /me/passkeys/register/startreturns options; the client invokesnavigator.credentials.create(); the response is verified atPOST /me/passkeys/register/finish. - Discoverable login:
POST /login/passkey/discover/startissues a challenge;navigator.credentials.get()returns a credential the server verifies atPOST /login/passkey/discover/finish. On success the user is logged in directly — no password needed. - Counter regression check: every signed counter is compared to the stored last-seen counter. A regression aborts the verification (defends against credential cloning).
- Challenges live in
webauthn_challengeswith a 5-minute TTL and are swept periodically. - The relying-party identifier is
RP_IDfrom the environment. Passkeys created againstRP_ID=localhostwon't work after you flipRP_IDto your real domain — users have to re-register.
- Token format:
stohr_pwr_<base64url(32 bytes)>— 256 bits of entropy. - Stored as SHA-256 hash in
password_resets. Plaintext only exists in the email link. - 1-hour TTL; single-use (
used_attimestamp set on apply). - Rate-limited per email (5/hour) and per IP (30/hour) to prevent enumeration / floods.
- Apply path revokes all of the user's existing sessions.
The reset-link URL is built from APP_URL. In production this must be HTTPS — otherwise the token rides in plaintext over the wire.
- Authorization-code grant with mandatory PKCE (S256). Implicit grant is not supported.
- Authorization codes are 60-second TTL, single-use (atomic UPDATE … RETURNING claims them).
redirect_uriis matched exact-string only against the registered list — no prefix or wildcard matching.- Confidential-client secrets are SHA-256 hashed at rest and compared with
crypto.timingSafeEqual. - Refresh tokens rotate on every use; presenting a previously-revoked refresh token burns the entire chain (reuse-detection per RFC 6749 §10.4).
- Access tokens are short-lived JWTs (1h) carrying scope + client_id; refresh tokens are 30 days.
- Device flow polling is server-rate-limited per RFC 8628.
Sliding-bucket counters in the rate_limits table, keyed by IP and / or identity:
/login— 5 per identity / 30 per IP per 15 min/signup— 10 per IP per hour/login/mfa— 6 per user / 30 per IP per 15 min/password/forgot— 5 per email / 30 per IP per hour/password/reset— 30 per IP per 15 min- Share access, OAuth token, and admin endpoints all have their own buckets — see
src/security/ratelimit.tscallers.
Limit hits return 429 with { error, retry_after } (seconds).
The IP used for a bucket comes from clientIp(req), which honors X-Forwarded-For / X-Real-IP only when the socket peer matches a configured TRUSTED_PROXIES CIDR. Otherwise the raw socket peer is used. This stops a remote attacker from spoofing IPs in headers to dodge limits or pin a victim's bucket.
audit_events records security-relevant actions: signups, logins (ok / fail / rate-limited / MFA), MFA enable/disable, password changes, password resets (request + apply), session revocations, OAuth grants and refreshes, OAuth refresh-token reuse detection. Owner-only view at Admin → Audit.
Stored fields: actor user, IP, user agent, structured metadata. Passwords, codes, tokens, and other secrets are never included in metadata — verify this when adding new audit calls.
The HTTP server wraps every response with:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preloadX-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()Cross-Origin-Opener-Policy: same-originCross-Origin-Resource-Policy: same-siteContent-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'(production). Dev mode loosensscript-srcandconnect-srcso Bun's HMR works.
'unsafe-inline' is intentionally allowed for style-src only — the SPA uses inline style= attributes. Inline scripts are blocked, so an XSS payload can't exfiltrate the bearer token from localStorage.
/files/:id/download?inline=1, /s/:token?inline=1, and /p/files/:id?inline=1 only render with the original Content-Type if the file's stored MIME is in a strict allowlist (image/*, video/*, audio/*, application/pdf, text/plain). Anything else — uploaded HTML, SVG, XML — is forced to Content-Disposition: attachment with Content-Type: application/octet-stream. SVG is explicitly excluded because it parses as XML and runs script.
This closes the stored-XSS path where an attacker uploads evil.html declared as text/html and lures a victim to open the inline link.
Public share creation requires an expiration (max 30 days). Optional password is Argon2-hashed; verified via the X-Share-Password request header at access time. The header is the only accepted password channel — query-string passwords (?p=) are rejected, since they end up in browser history, server access logs, and Referer headers.
Optional "burn after view by non-creator" atomically deletes the share row before serving — only one non-owner viewer wins.
A periodic sweep (guarded against overlap) deletes expired share rows hourly; lazy-delete also runs on access.
Invites are stored as SHA-256 hashes (migration 00000032). The plaintext appears only in the response to POST /invites and in the email Stohr sends to the recipient. List/admin views show metadata but never the token.
This means a Postgres dump or read-replica leak does not yield usable invite codes.
- Admin routes (anything under
/admin/*) checkusers.is_owner = trueby querying the database on every request rather than trusting the JWT claim. A demoted owner loses access immediately rather than at JWT expiry. - Folder and file authorization (
src/permissions/index.ts) walks the folder ancestry in a single recursive CTE and returns the nearest collaboration grant; folder grants cascade to descendants.
Stohr does not encrypt file bytes at the application layer. Encryption-at-rest is the responsibility of the storage backend. Recommended setups:
| Backend | Encryption at rest |
|---|---|
DigitalOcean Spaces (s3) |
AES-256 by default for every object — nothing to configure |
AWS S3 (s3) |
Enable bucket default encryption (SSE-S3 / SSE-KMS) in the bucket settings |
MinIO self-hosted (s3) |
Set MINIO_KMS_AUTO_ENCRYPTION=on and configure a KMS key |
RustFS / generic S3 (s3) |
Confirm the provider's encryption-at-rest behavior; otherwise enable disk-level encryption (LUKS, FileVault, etc.) on the host |
Local disk (local) |
No application-layer encryption. Put STORAGE_LOCAL_DIR on a LUKS / FileVault / dm-crypt / EBS-encrypted volume |
JWT secrets, password hashes, TOTP secrets, PAT hashes, invite-token hashes, password-reset hashes, OAuth client-secret hashes, and refresh-token hashes all live in Postgres — encrypt the database volume at rest as well. DigitalOcean managed Postgres encrypts at rest by default; self-hosted Postgres should sit on an encrypted filesystem.
TRUSTED_PROXIES is a comma-separated list of IPv4 CIDRs that are allowed to set X-Forwarded-For / X-Real-IP. Behind a load balancer / Caddy / nginx, set this to the proxy's source range (e.g. 172.16.0.0/12 for the Docker bridge that compose creates). Leave it empty if the API receives traffic directly.
Setting it wrong has real consequences:
- Too narrow → the API records the proxy IP for every request, collapsing rate-limit buckets and audit logs onto a single IP. One bad actor locks out everyone.
- Too wide → an attacker can spoof XFF and either evade their own bucket or pin someone else's.
MAX_UPLOAD_BYTES (default 1 GiB) is the hard cap on a single request body. Bun buffers the body in memory before the handler runs; with STORAGE_DRIVER=s3 the @atlas/storage driver re-buffers it again to compute the SigV4 payload hash, so this is effectively a per-upload memory ceiling. The local driver streams to disk after Bun's initial buffer.
Direct-to-bucket presigned PUTs are intentionally not supported — all file CRUD goes through the API so authorization, quota, and audit checks always apply.
- Application-layer encryption for TOTP secrets and (eventually) file-level E2E
- Bucket-default-encryption assertion at API startup (probe the bucket and warn if the provider does not advertise encryption)
- External pen test / bug bounty — recommended before public launch
- Streaming uploads (write each chunk straight to the storage driver as Bun receives it) to remove the in-memory buffering ceiling without giving up the API-only CRUD invariant
Report security issues privately to me@wess.io. Please don't open a public GitHub issue for anything that could be exploited. We aim to acknowledge reports within 48 hours.