← Auth / Security

Protection and infrastructure

auth / security · workzone
Inbound HTTPS request
1
TLS termination
The request is accepted only over an encrypted channel — otherwise it is rejected.
HTTPS only TLS 1.3 · 1.2 min HTTP → redirect Certificates Let's Encrypt / corporate CA
Edge hardening → resource limits at Nginx, ahead of the app: client_max_body_size, client_*_timeout, limit_conn, large_client_header_buffers. Earlier and coarser than limits ③⑤: on the raw connection, not on an identified client.
2
Security headers · Nginx
Every response is augmented with protective headers that hand the browser its rules.
HSTS max-age=1y; includeSubDomains CSP default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self' X-Content-Type-Options nosniff X-Frame-Options DENY Cross-Origin-Opener-Policy same-origin Cross-Origin-Resource-Policy same-origin Referrer-Policy strict-origin-when-cross-origin Permissions-Policy Cache-Control no-store on sensitive responses Clear-Site-Data on logout Token routes no-referrer Server · X-Powered-By off
3
CORS · trust boundary
A FastAPI middleware decides which origin to trust.
Allow-Origin whitelist from env Allow-Credentials true Allow-Methods GET/POST/PUT/DELETE/PATCH Allow-Headers Authorization, Content-Type, X-Request-Id · X-CSRF-Token v2 Expose-Headers Retry-After, X-RateLimit-Remaining, X-Request-Id Preflight Max-Age 3600 API rate limiting per-user, by role · Redis >
4
CSRF check → Refresh cookie
A mutating request must prove it was sent from our own origin.
SameSite=Strict on the refresh cookie Origin / Referer on every mutation
SSO v2 → switch to SameSite=Lax + a CSRF token for cookie-authenticated endpoints (/refresh).
Protecting the provider login itself (state / PKCE / nonce) is the job of the SSO / OIDC card.
5
Login uses all three layers; the per-account part (layers 2–3) also covers the current-password check on password change. Brute force is slowed down without locking out the owner.
Layer 1 Rate limit by IP — 20 attempts / 15 min per IP. Behind a reverse proxy the IP is taken from X-Forwarded-For (from trusted proxies only).
Layer 2 Per-account delay — the first 2 failures with no delay (headroom for typos), from the 3rd exponentially 1s→2s→4s→8s (capped at 30s), server-side.
Layer 3 Alert Owner/Admin after 10 per-account failures — a notification.
Uniform error response Counter reset on success / TTL 15 min Attempt log pre-auth, IP + email State in Redis brute:ip · brute:account
Delay, not a hanging connection → past the threshold we return 429 + Retry-After rather than holding the request open: otherwise the hanging connections themselves become a load vector.
Non-existent email → a dummy argon2 verify (constant-time): the response takes as long as it would for a real account.
MFA → its own limit, separate from the password: 5 failed TOTP or recovery-code entries burn the intermediate login token — after that, start over from the password. Brute-forcing a 6-digit code through the MFA step won't get through this way.
Messenger linking code → its own limit per chat_id: 5 failed entries burn the attempt — get a new code in the web app. The bot's public webhook is under rate-limit by IP/source; an expired and an invalid code are indistinguishable (a single 410 LINK_EXPIRED). The bot is open to everyone, so the line is stricter than for the workspace-closed Slack.
Limits of the line and deferred measures
v2 An invite-only platform, with no public sign-up → the main threat is credential stuffing against specific accounts; the delay + MFA close it without friction. CAPTCHA will return adaptive (only on risk signals) if operational logs show the need.

Distributed brute force (a botnet, password spraying — one password across many accounts) isn't caught by the per-IP limit: just a couple of attempts from each address. At this early stage it's an accepted limitation; a global anomaly detector awaits v2, driven by operational logs.

The same uniform response applies to password recovery: “if the account exists, an email has been sent,” regardless of whether it does.
6
A cross-cutting line: everything that is generated, compared, and stored on disk.
Does the server need the original secret back?
No Hash · irreversible
argon2id slow
  • Passwords
  • Recovery codes
memory 19 MB iterations 2 parallelism 1
SHA-256 fast
  • Refresh tokens
  • Reset links
  • API keys
  • CSRF v2
Yes Encryption · reversible
AES-256-GCM by key
  • smtp_settings.password_enc
  • sources.credential_enc
  • sources.webhook_secret_enc
  • ai_providers.api_key_enc
  • tools.credential_enc
  • notification_channels.url_enc
  • notification_channels.secret_enc
  • mfa_secret v2
Token CSPRNG secrets.token_urlsafe() ≥32B Constant-time hmac.compare_digest() Token hash in the DB SHA-256 JWT signing HMAC-SHA256
Encryption at rest v2
mfa_secretAES-256-GCM (the secret is needed reversibly: the server computes TOTP from it). Key from env/vault, key rotation across several versions (key ID in the data). Recovery codes are hashed, not encrypted (argon2id), like passwords: one-time, the server only verifies them.
7
On the way out, every significant event is recorded permanently.
What we log significant events, not every request Format relational schema + JSONB Storage append-only PostgreSQL · Owner only
Our own table, not a library · and a second defense-in-depth layer
Our own table. A thin wrapper, audit_log(actor, action, target, result, ip, ua), records the actor's intent, not a row diff (off-the-shelf row-versioners handle that). Application default: OWASP ASVS V7, NIST SP 800-92.

A single write point. The wrapper is called centrally — middleware on mutating endpoints plus domain events, not a scatter of manual calls: a forgotten call is a hole in the audit with no signal at all.

Hash-chaining v2. Each entry stores the hash of the previous one — the log becomes tamper-evident: a row can't be edited or deleted after the fact unnoticed, the hash chain would break. On top of append-only rights, for a high level of trust.
What the audit never captures → passwords, tokens, and secrets — never; a sensitive field goes in by identifier, not by content (old→new only for the non-confidential: role, status). The log is undeletable and permanent — PII that lands here can no longer be scrubbed out.
Writing the audit → a login is written asynchronously: an audit-log failure doesn't bring down the login itself. Permission changes — the write is mandatory and in its own transaction, so that result=failure isn't lost if the main operation rolls back.
Retention and archival v2 → the audit_retention_days config, defaulting to “forever.” The cleanup job itself and the export to cold storage are designed together with hash-chaining — deletion must play nicely with immutability, and the term is set by compliance (SOC 2 / ISO 27001).
Least-privilege DB role → the app connects to Postgres with a role that has no DDL and no access to other schemas: even a query breached by injection is locked inside its own tables. Defense-in-depth beneath the data layer, not parameterization alone.
Direct DB access bypassing the app → caught by pgaudit v2: SQL operations are logged in the database itself, which the application audit can't see. Forensics for compliance (SOC 2 / ISO 27001).
✓ Request cleared protection → processing a request that passed every line is admitted to the business logic; its trace is in the audit log