Security Overview
Flo's security posture is reviewed with the OWASP Top 10:2025 methodology across three rounds. This page synthesizes the protections that are actually in the codebase: authentication, rate limiting, headers, secrets at rest, and audit/forensics, followed by the remediation history and the operational rules that keep the posture current.
Authentication protections
Password login, OTP login, and OIDC share the same cookie session model. The flows are described in Authentication; the security-relevant controls are:
- Password hashing with BCrypt. The policy is a minimum of 8 characters and
a 72-UTF-8-byte maximum (BCrypt truncation), plus an offline blocklist of
common and breached passwords that also rejects classic mutations
(
Password1234,p@ssw0rd!). SeePasswordHelperandPasswordBlocklist. - Login lockout: 5 failed attempts trigger a 15-minute lockout per account.
Counters live in the
LoginAttempttable, so blue-green containers and process restarts share one view; stale rows are swept opportunistically and a successful login clears the counter. - OTP hardening: 6-digit codes, 10-minute expiry, 3 attempts per code, 30 s resend cooldown, constant-time comparison, hashed at rest, and identical responses for known and unknown emails (no enumeration).
- Token storage: password-reset and email-confirmation tokens are stored as hashes, not in clear.
- Session protections: the
FloAuthcookie is HttpOnly, Secure, SameSite=Lax and Data-Protection encrypted. On top of the sliding expiry,AuthSessionServiceenforces an absolute 7-day session lifetime and server-side invalidation (global and per-user). Logout revokes OpenIddict refresh tokens and marks cookie sessions stale; password changes and resets invalidate that user's other sessions. - Live authorization:
LoginAccessMiddlewarere-checks login policy and the user's role claims on every authenticated request, re-issuing the cookie when the database privileges changed. Seeded operator passwords force a password change at first login: the dashboard redirects to the change page and the API answers403until the password is rotated. - Enumeration mitigations: a dummy BCrypt verify equalizes the missing-account path, and password recovery returns one message for both hit and miss.
Rate limiting
Rate limiting is enforced at the application level (ASP.NET rate limiter policies). The main policies:
| Policy | Partition | Limit |
|---|---|---|
AuthEndpointsLimiter | client IP | 20/min (login, register, recovery; off in Development) |
PublicOtpValidateLimiter | client IP | 10/min |
PublicBrowserLimiter | client IP | 60/min (public forms, OTP request) |
PublicIntegrationLimiter | token hash (or IP) | 300/min |
PaymentWebhookLimiter | client IP | 120/min |
BookingCheckoutLimiter / CommerceCheckoutLimiter | user (or IP) | 10/min |
PresignRateLimiter | user | 120/min |
MediaUploadRateLimiter | token hash (or IP) | 120/min |
PublicApiLimiter | fixed window | 10/min, 2 queued |
Rejections return 429 with errors.general.rateLimitExceeded. Partitions are
resolved by ClientIpResolver: the CF-Connecting-IP header is honored only
when TRUST_CF_CONNECTING_IP is enabled and the immediate peer is inside the
configured FORWARDED_PROXIES / FORWARDED_NETWORKS boundary, so a spoofed
header cannot mint fresh full-quota partitions. The limiter state is in-process:
today Flo runs one backend instance per tenant, and horizontal scaling would
require a shared backing store.
nginx.conf.template also defines per-IP limit_req zones, but the production
fleet runs Docker + Traefik with the application limiters; nginx is only used by
the single-tenant/local Compose path (documented exclusion X-07).
Headers and CSP
For nginx deployments, nginx.conf.template sets HSTS, a Content-Security-Policy
(default-src 'self', script hash allow-list, frame-ancestors 'self'),
X-Content-Type-Options, X-Frame-Options: SAMEORIGIN, Referrer-Policy,
Permissions-Policy (geolocation, microphone, camera disabled), and
X-Robots-Tag: noindex on the API. Locations that declare their own
add_header repeat the security set, because nginx drops inherited headers in
that case.
The Cloudflare Pages SPA ships Flo.FE/public/_headers with nosniff,
X-Frame-Options, Referrer-Policy, and Permissions-Policy. CSP is deferred
for the SPA because the login page loads third-party SDKs (Google Identity
Services, Apple JS); the plan is to ship it Report-Only first.
API responses default to no-store, the exception middleware returns localized
error codes without stack traces, and the server banner is suppressed.
Secrets at rest
- Tenant secrets: the CLI encrypts secret values in tenant
.envfiles with AES-256-GCM (authenticated tag, scrypt-derived key), keeping themaster.keyat0600. Rotation is handled by Secrets Rotation. - Webhook auth tokens are encrypted at rest (ASP.NET Data Protection) and never returned in clear.
- API tokens are stored as a SHA-256 hash plus an 8-character prefix; presign bindings hold token hashes, not the raw token.
- OTP codes and reset tokens are hashed at rest; access tokens are encrypted in the OpenIddict store.
- Backups carry
.sha256sidecars that are verified before a restore; pre-deploy dumps are created per deploy. - Supply chain: container images are pinned by digest in CI and the
Dockerfile,
npm ci --ignore-scriptsis used, dependencies are locked, and a weekly SCA workflow runs.
Audit and forensics
The full surface is described in Settings and Audit. Key properties:
- The audit trail is append-only: the DbContext rejects updates and deletes
of
AuditLogrows; entity changes are staged in the same transaction as the change with actor, action, old/new values, and timestamp. - Retention defaults to 730 days, configurable between 30 and 3650; anonymization and purge operations append tombstones so the log stays self-describing after redaction. GDPR export and anonymization endpoints are SuperAdmin-only.
- Auth events (
/api/v1/auth-events) record masked server- and client-side login failures; password-login failures and lockouts are recorded through the same seam. The viewer lives at Settings > Accessi and errori. - Logs: rolling Serilog files with a SuperAdmin-only viewer, CSV/JSON
export with spreadsheet-formula neutralization, query-string token redaction,
and masked PII in auth log lines. Requests carry an
X-Correlation-IDheader for tracing.
OWASP remediation history
| Round | Target | Outcome |
|---|---|---|
| 1 (2026-09-18) | Backend API surface + infra (Flo.BE/**, Dockerfile, compose, nginx, CI) | 41 findings, 4 critical (anonymous log endpoints, committed Jarvis token, seeded SuperAdmin credentials, log exposure); all fixed and re-verified dynamically |
| 2 (2026-09-18) | Deep dive: Flo.FE/src/**, cli/src/**, Flo.SchemaGen/** + replay of round-1 fixes | 22 findings fixed (FE XSS/returnUrl/upload, CLI argv secrets/backup extraction, SchemaGen permissions) and verified |
| 3 (2026-09-19) | Full-repo deep audit on HEAD 1e60ce1 + live anonymized clone | 40 findings across 10 domains (39 confirmed, 1 partial), 1 critical (committed Strapi token); fixes closed in bugfix/owasp-security-fixes with green suites and 9/9 dynamic proofs |
Notable round-3 fixes: OIDC logout revoking refresh tokens and server-side
sessions, OTP/reset tokens hashed at rest, password blocklist, masked auth logs
and sanitized log sinks, DB-backed lockout shared across containers, webhook
token encryption, 7-day absolute session cap, audit tombstones, forwarder trust
from FORWARDED_* env, Cloudflare Pages security headers, and digest-pinned
deploy images.
Permanent exclusions are tracked in the platform repository
(docs/SecurityExclusions.md): seeded SuperAdmin credentials (X-01), Origin as
defense-in-depth only for API tokens (X-02), MFA/step-up deferred (X-03),
SIEM-layer alerting (X-04), social SDKs without SRI (X-05), Flo.STRAPI out of
scope (X-06), and nginx not used in production (X-07). A finding that falls in
one of these entries is cited as excluded, not silently reopened.
Operational rules
- Keep the
ReleaseNotes.mdPre-deploy/Post-deploy checklists current: they are the release runbook (see Release Process). - Never deploy around the resource preflight, and never use
--forcewithout an explicit per-operation authorization. - Rotate secrets on a schedule and immediately after any suspected exposure;
flo secrets rotateappends new audit salts without invalidating old hashes. - Complete a full DR disarm before tenant maintenance or a MON deploy: lease guards fail closed and block writes without valid authority. See Failover and Disaster Recovery.
- Monitor the surfaces that carry security signals: auth events, audit trail, app logs, and fleet alerts (Monitoring, Control Plane).
- Never commit secrets or tokens. A committed credential is a security finding, not a configuration detail.