Security
Last reviewed against the codebase: 2026-07-25.
This is the security narrative at a level suitable for enterprise / Government reviewers and for a new maintainer who has to change the security-sensitive code without regressing it. Every claim here maps to code you can read and a test you can run. The exact routes and environment variables are generated from the codebase in the API reference and Configuration reference - this page explains the why and the failure modes, not the field list.
The one thing to understand first
The v1 deployment puts every user in a single shared organisation (org="default"). That is
a deliberate, documented v1 choice - but it means org_id is NOT a tenant boundary. If a
user-owned resource (a property, its risk, its evacuation route, a check-in session) were filtered
by org_id alone, every user would be authorised against every other user’s data.
The consequence drives most of the authorization design below: any route touching a
person-owned resource must scope by the owner (ownerUserId = sub), not just the org. This rule
lives in code, not in human memory - see lib/ownership.ts - precisely so IDOR can’t creep back
in when someone adds a new route.
Authentication & session model
Fire Path AI supports several sign-in methods that all converge on one bearer token:
- Local email + password - passwords are hashed with bcrypt (cost 12), never stored in plaintext. Registration and password rules are validated server-side.
- OIDC providers - Google and Microsoft sign-in verify a provider ID token, then map the
verified identity onto a Fire Path AI user. Google sign-in verifies signature and audience and
rejects unverified provider emails (an account-takeover defence - an unverified email must not
be trusted to bind an account). A provider is only enabled when its client ID is configured;
otherwise its route returns
503rather than failing open. - Device sign-in - a lightweight passwordless device identity for first-run mobile use, which can later be claimed by a full account.
Every path issues a signed JWT (HS256, algorithm pinned - no alg-confusion) carrying only
sub (user id), org_id and roles. The signing key comes from a single injected JWT_SECRET.
Tokens have a bounded TTL (JWT_TTL_DAYS, default 30). The verification surface is deliberately
isolated in middlewares/auth.ts so the underlying scheme can be swapped (for example to
JWKS-backed OIDC/SAML) without touching route handlers - the plan is to replace the body of
verifyToken() with a jwtVerify(token, jwks) call and leave the middleware contract unchanged.
Revocation, and why a transient blip must never sign everyone out
Explicit sign-out sets a per-user revocation timestamp (tokenRevokedBefore); a token whose iat
is at or before that instant is rejected before its TTL expires, so logout takes effect
immediately. A deleted user’s tokens are treated as revoked.
The subtle part is error classification, and it is load-bearing for a life-safety app:
- A bad / expired / revoked / malformed token is the caller’s problem →
401. - A server problem - missing
JWT_SECRET, or a DB outage during the revocation lookup - is our fault →500, never a401.
Why this matters: the revocation check does one DB read per protected request. If a 500-class
failure were returned as 401, a momentary DB blip would look like “everyone’s token is invalid”
and silently sign out every active user mid-fire. The code resolves the secret outside the
verify try/catch and lets DB errors propagate as a non-AuthError, so those become 500. The
underlying error text (e.g. "JWT_SECRET is not set") is never echoed to the client.
Authorization & tenant isolation
Authorization is layered:
- Owner scoping (the real isolation boundary today) - user-owned resources are constrained to
ownerUserId = subvia the single helperlib/ownership.ts. A miss returnsnulland the caller responds404(not403- we don’t confirm the object exists to a non-owner). IDOR hardening was a dedicated audit workstream; there is a regression suite that asserts no cross-user200. - Role-based access control - admin/agency routes require a role
(
homeowner | agency_officer | council_admin | system_admin) viarequireRole([...])inmiddlewares/rbac.ts. Critically, RBAC reads the user’s current roles from the database on each gated request (joiningmemberships → roles) rather than trusting the possibly-weeks-old JWT claim, so a revoked role stops working immediately. This is one query per RBAC-gated request; those endpoints are low-volume (admin/telemetry) so the cost is negligible. - RBAC fails closed. If the role-revalidation query errors, the request is denied
(
503 Authorization temporarily unavailable) - never allowed on a stale claim. Denials are written to the audit log (action: "rbac.denied") so a reviewer or dashboard can see attempted privilege escalation.
Verify it:
# from the api-server package - needs local Postgres up (see below)pnpm --filter @workspace/api-server exec vitest run test/idor.test.tspnpm --filter @workspace/api-server exec vitest run test/predictions-idor.test.tspnpm --filter @workspace/api-server exec vitest run test/admin-rbac.test.tspnpm --filter @workspace/api-server exec vitest run test/rbac-revalidation.test.tsExpected: IDOR suites show no cross-user 200 (cross-user reads 404); admin-rbac shows
homeowner → 403, admin → 200; rbac-revalidation shows a role removed in the DB is rejected
even though the JWT still carries it.
Input validation & injection
- Validation - request
body/query/paramsare validated with Zod schemas before any handler logic runs: bounded string lengths, the Australian bounding box + lat/lng ranges, capped pagination, and UUID-validated path ids. An unvalidatedreq.body/req.query/req.paramsis treated as a defect. - SQL injection - none. All dynamic SQL uses Drizzle parameterised tags; the handful of
sql.rawsites wrap a numeric constant, not user input. - SSRF - none. Every outbound
fetchtargets a hard-coded host; user input only ever appears in validated query parameters, never in the host.
These last two are grep-auditable, and the security runbook expects a reviewer to re-read each hit rather than trust the summary:
grep -rn "sql.raw" Fire-Path-AI/artifacts/api-server/src # expect: only numeric constantsgrep -rn "fetch(" Fire-Path-AI/artifacts/api-server/src # expect: every host a hard-coded constantTransport & API protections
- Transport - all client traffic is HTTPS. Cloudflare → the AWS ALB (TLS 1.3 policy,
HTTP→HTTPS redirect, invalid headers dropped) → RDS over
sslmode=require. Internal tiers live in private subnets and are not publicly reachable; only the ALB is exposed (ports 80/443). See Infrastructure for the topology. - Security headers - the API sets HSTS plus a strict helmet CSP (
default-src 'none'; frame-ancestors 'none'),X-Content-Type-Options: nosniff,Referrer-Policy, andX-Frame-Options: DENY. The web app ships the same header family via its edge config, with the CSP running Report-Only first (promoted to enforcing after validating against the deployed bundle - see caution below). - Rate limiting - authenticated traffic is throttled per user (
middlewares/userRateLimit.tskeys onreq.user.sub, falling back to per-IP ifreq.useris somehow absent - defence in depth). Auth endpoints carry their own tighter per-IP limiters, including a dedicated limiter on the sign-in flow to blunt scripted email enumeration. - Reset / verify tokens - single-use, hashed at rest, delivered by
POSTbody (never in the API URL), and the SPA scrubs the token from browser history after capture.
Verify the headers:
# unit-level (no deploy needed)pnpm --filter @workspace/api-server exec vitest run test/security-headers.test.ts# against the live APIcurl -sI https://api.firepath.software/api/healthz | grep -iE "strict-transport|content-security"Secrets
Secrets live in AWS Secrets Manager and are injected into the runtime as environment variables
at task start - never committed to the repository, and never assembled into a single blob. The
database URL, for example, is composed from separate secret keys at boot (host / port / name /
user / password), so no single secret holds the full connection string. Configuration is read
through one typed loader (lib/config.ts), so there is exactly one place each variable is
named - JWT_SECRET, the FIRMS key, the email-provider key, etc. Non-secret defaults are
non-personal role addresses; any personal or environment-specific value is supplied only via a
gitignored config file or the injected secret. Optional integrations (OIDC, SMS, email) stay inert
unless their credentials are present.
There is no secret rotation automation described here beyond the platform primitives (KMS CMK rotation for data-at-rest keys); rotating an application secret is a Secrets Manager update plus a task redeploy so the new value is picked up at boot. See Operations for the deploy/redeploy mechanics.
No secrets in git is enforced, not assumed:
gitleaks detect --source . --redact # expect: no findingspnpm audit --audit-level=high # expect: no high/critical (or each documented)PII & data protection
- PII held - email, phone, home address, and location. Encrypted at rest (RDS + KMS, ElastiCache at-rest + in-transit encryption) and in transit (TLS end-to-end).
- Logging redaction - the logger redacts
email/phone/displayName/address/location/password/token, and the request serializer strips query strings, so PII never lands in logs. No call site logs a full user object. - Access - PII routes are owner-scoped (above) and audited.
- Residency - all data lives in AWS Sydney (
ap-southeast-2).
Audit logging
Authenticated requests are recorded to an append-only audit_log. Volume is kept sane by
sampling high-volume read polling, but auth events, admin actions, write methods, and RBAC
denials are never sampled - they are always logged. Liveness/readiness probes are never audited.
Crucially, a persistent failure to write the audit trail is surfaced as an operator error log +
metric (an alarm can page on it) rather than a silent warn, because a quietly-stopped audit
trail is a compliance and forensics gap you would only discover during an incident. See
Operations for the alarm classes.
Resilience as a security property
For a life-safety app, a silent failure is a security failure. The relevant guarantees:
- Every external
fetchhas a timeout + bounded retry (lib/http/withFetch.ts) - a hung upstream can’t stall the fire feed (test/withFetch.test.ts). - FIRMS quota/key errors that arrive as
HTTP 200with an error body fail loudly instead of silently recording “0 fires”. - Emergency alerts never age out of the delivery queue; undelivered ones escalate to an
errorlog (alarm hook). Alert dedup is race-safe (partial unique index +ON CONFLICT), and Expo delivery is confirmed per ticket, not by a blanket HTTP200(test/alert-delivery.test.ts). /readyzreflects real DB + ingest-freshness state, so a degraded backend is visible rather than pretending to be healthy.
Keeping personal data out of published docs
These docs are generated partly from the codebase, so a redaction gate
(scripts/redaction-gate.mjs) runs on every build and fails the build (non-zero exit) if the
built dist/ contains any personal data, secret value, or infrastructure identifier - machine
paths, 12-digit AWS account ids, vpc-/subnet-/sg-/… resource ids, ARNs, *.rds/cache/elb
endpoints, personal emails/names, internal tooling references, or links to internal-only docs. The
gate is wired into the build, not a manual step:
cd Fire-Path-AI/artifacts/docs-sitepnpm run build # runs gen → check:reference → astro build → check:redaction → check:links# or just the gate against an existing build:pnpm run check:redaction # node scripts/redaction-gate.mjs distThe same denylist protects the marketing site, so both public surfaces are scanned by identical
rules. If the gate blocks your build, it will name the file and rule id (e.g. R2 = AWS account
id) - fix the source content, don’t weaken the rule. The full gate list and the doc-drift gate are
described in Testing & quality.
Where to go next
- Operations - deploy pipeline, alarms, and the incident runbook by alarm class.
- Infrastructure - network topology, private subnets, TLS termination.
- API reference and Configuration reference - the generated route and environment-variable inventory.
- Testing & quality - coverage gates, the redaction/drift gates, and the “CI never gets real secrets” rule in full.