Skip to content

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 503 rather 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 a 401.

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 = sub via the single helper lib/ownership.ts. A miss returns null and the caller responds 404 (not 403 - 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-user 200.
  • Role-based access control - admin/agency routes require a role (homeowner | agency_officer | council_admin | system_admin) via requireRole([...]) in middlewares/rbac.ts. Critically, RBAC reads the user’s current roles from the database on each gated request (joining memberships → 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:

Terminal window
# from the api-server package - needs local Postgres up (see below)
pnpm --filter @workspace/api-server exec vitest run test/idor.test.ts
pnpm --filter @workspace/api-server exec vitest run test/predictions-idor.test.ts
pnpm --filter @workspace/api-server exec vitest run test/admin-rbac.test.ts
pnpm --filter @workspace/api-server exec vitest run test/rbac-revalidation.test.ts

Expected: 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 / params are 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 unvalidated req.body/req.query/req.params is treated as a defect.
  • SQL injection - none. All dynamic SQL uses Drizzle parameterised tags; the handful of sql.raw sites wrap a numeric constant, not user input.
  • SSRF - none. Every outbound fetch targets 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:

Terminal window
grep -rn "sql.raw" Fire-Path-AI/artifacts/api-server/src # expect: only numeric constants
grep -rn "fetch(" Fire-Path-AI/artifacts/api-server/src # expect: every host a hard-coded constant

Transport & 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, and X-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.ts keys on req.user.sub, falling back to per-IP if req.user is 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 POST body (never in the API URL), and the SPA scrubs the token from browser history after capture.

Verify the headers:

Terminal window
# unit-level (no deploy needed)
pnpm --filter @workspace/api-server exec vitest run test/security-headers.test.ts
# against the live API
curl -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:

Terminal window
gitleaks detect --source . --redact # expect: no findings
pnpm 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 fetch has 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 200 with 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 error log (alarm hook). Alert dedup is race-safe (partial unique index + ON CONFLICT), and Expo delivery is confirmed per ticket, not by a blanket HTTP 200 (test/alert-delivery.test.ts).
  • /readyz reflects 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:

Terminal window
cd Fire-Path-AI/artifacts/docs-site
pnpm 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 dist

The 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