Security
Flightdeck’s security posture is a set of properties enforced at the layer where they cannot be forgotten: in the database schema, at the composition root, and at the two network edges. This page is the trust model in full — what each credential proves, what a scope can and cannot do, how tokens are handled, and the specific threats each control closes.
Credential-carries-ownership
Tenant-facing auth follows one pattern everywhere (ADR-0001, FD-8): API keys are resource-bound rows. A key’s scope says “some tenant can do X”; the key row says which tenant. Ownership is always read from the credential, never from a path or body parameter the caller supplied. This is the enforcement half of fail-closed tenancy — there is no default tenant to fall back to.
The merchant fdk_ credential (defined in modules/stores) is the concrete
case:
- Stored hashed. Only
sha256(key)is written tostores.tenant_credentials.key_hash; the plaintext key is returned exactly once at issuance and never again.verifyCredentiallooks a caller up bykey_hashwhererevoked_at IS NULL, returning the owningtenant_refand scopes — an unknown or revoked key resolves to nothing. - Bound to a tenant in the row.
stores.tenant_credentialscarriestenant_refon the credential row itself. Every tenant-facing route resolves the tenant from the verified credential, so one tenant’s key can never read or write another tenant’s data. - Rate-limited before the lookup.
verifyTenantAuthincrements a per-key bucket (fdk_verify) before touching the database, so an online key-guessing attempt is throttled (429withretry-after) rather than turned into unbounded hash lookups. A lookup failure is503 auth_unavailable, never a silent pass.
The scope model
Scopes are a closed allowlist — TENANT_SCOPES in
modules/stores/src/domain/credentials.ts. parseScopes rejects any string
not in that list and requires at least one, so a credential can never carry a
scope the platform does not define. Scopes are coarse and ownership-shaped
(read:own_orders, manage:own_catalog, manage:own_domains,
manage:own_credentials, and so on).
Two rules keep the scope system from becoming an escalation vector:
- Subset-gating. A key can only mint another key whose scopes are a
subset of its own (
scopesAreSubset).POST /v1/my/credentialsrequires themanage:own_credentialsscope, and any requested scope the issuing key does not hold is a403 scope_escalationnaming the excess scopes — a merchant key can never manufacture a more powerful key. - First key by identity session, not by another key. The bootstrap
problem — how do you get your first credential — is solved by
POST /v1/my/credentials/initial, which authenticates an identity session bearer (a signed-in human), not anfdk_key. It is owner-only, the tenant is derived from the session, and the scopes granted depend on store status: alivestore gets the full merchant scope set, adraftstore gets a deliberately narrow pre-live build set (store, catalog, media, domains — no money scopes, and notably notmanage:own_credentials), asuspendedor missing store mints nothing.
Because the initial-credential broker derives the tenant from the session and refuses any non-owner, a compromised or malicious caller cannot mint a key for a tenant they do not own — the cross-tenant mint is unconstructible, not merely checked.
Token handling — the storefront token stays server-side
There are three distinct credential families, and the important security property is where each one is allowed to live.
| Credential | Header | Verified by | Lives where |
|---|---|---|---|
Merchant fdk_ key | Authorization: Bearer fdk_... | hash lookup, tenant from row | server-side, per tenant |
| Storefront token | x-storefront-token | constant-time compare | server-side only |
Operator session fdo_ / operator token | authorization / x-operator-token | session verifier / constant-time compare | operator tooling only |
The storefront token is the credential a headless frontend uses to reach
public commerce and checkout routes. Today it is a single platform-wide
static secret (STOREFRONT_API_TOKEN), operator-provisioned — there is no
self-serve mint — and it is verified with the same constant-time comparison
used for the operator token. It is meant for a server-side runtime only;
the SDK’s storefront client sends it as x-storefront-token from a server,
never from a browser. There is deliberately no browser-side SDK and no CORS
opening (ADR-0008, ADR-0014) — a token in browser
JavaScript is a token you have published. The token string is also a
redaction target in the logging and Sentry-scrubbing layers, so it cannot
leak through an error report.
Never ship the storefront token to a browser, a mobile app bundle, or any client you do not control. It is platform-wide today: one leaked token is not scoped down to a single tenant. Keep it on your server and proxy storefront calls through your own backend.
The operator front door
Operator (internal staff) access is gated once, at the composition root, in
app/src/operator-auth.ts — mounted before any module so no operator route is
reachable around it. It does three things on every request:
- Strips
x-operator-actorfrom the inbound request. Only this middleware may assert who the operator is; a caller cannot forge an actor. - Resolves an
fdo_operator session against the identity module’s session verifier, enforcing per-area scopes. Postures are fail-closed: a verifier infrastructure failure is503 auth_unavailable; an unknown or expired session is an opaque401; a session on a path outside the registry, or missing the area scope, is403 insufficient_scope. - On success, exchanges the session for the in-process static operator token and stamps the verified actor, so every module’s own route gate (conventions rule 6, ADR-0011) stays untouched and the static token never leaves the process.
Operator roles are a closed set (admin, support, finance) mapped to
area scopes (operator:money, operator:commerce, and so on). Notably
support does not carry operator:money. built-must-be-wired extends here:
a mounted module with no operator area mapping is a boot error, because a
module the front door cannot place would silently refuse every operator
session.
The SSRF guard on outbound webhooks
Merchant webhook URLs are attacker-influenced — a merchant can point one at
http://169.254.169.254/ and try to make the platform fetch its own cloud
metadata. edges/outbound-webhooks closes this at two enforcement points:
at subscription-create time, and again immediately before every dispatch,
retry, and redrive.
- Hostname guard (
assertPublicHttpUrl): the scheme must behttp/https(invalid_scheme);localhostand the cloud metadata address169.254.169.254are rejected explicitly; and any literal IP in a private or special range is rejected (private_host) — IPv410/8,172.16/12,192.168/16,127/8,169.254/16,0/8, plus IPv6 loopback, unique-localfc00::/7, link-localfe80::/10, and IPv4-mapped forms unwrapped and re-checked. - DNS-rebinding close. A hostname that passes the string guard is still
a TOCTOU risk: DNS could resolve to a public IP at check time and a private
one at connect time. The dispatcher resolves the hostname exactly once,
re-applies the private-range checks to the resolved address, and then
pins the TCP connect to that literal IP — nothing re-resolves DNS after
the pin. TLS SNI and certificate verification still target the real
hostname. Every delivery uses this pinned client, with a 10-second timeout;
a plain
fetchis never used for egress.
There is a single dev escape hatch (OUTBOUND_WEBHOOKS_ALLOW_LOOPBACK=1)
that permits loopback for local testing — and it hard-throws when
NODE_ENV=production, and still rejects malformed URLs, non-http schemes,
and the metadata address.
The secret model
Secret management is a compliance control, not a convenience (ADR-0009). The rules that matter to anyone reading config:
- No empty-string defaults, ever.
requiredEnvthrows at boot on a missing or empty value.env("X") ?? "",X || "default", andnew Secret("X", "")are banned. A missing secret crashes the process with a named error rather than shipping an empty credential that reads as “set.” - Fail-startup at construction.
requiredEnvis what each module receives as itsenvdependency, so any secret read at module construction fails the whole boot. Config that only one route needs is read lazily, so that surface simply refuses when the value is absent rather than blocking boot. - No
AUTH_DISABLED. There is no global auth-off switch anywhere. - Secrets never touch disk in dev. Authoring lives in one 1Password
vault; local and CI runs inject values through
opfromop://references, and production reads from AWS Secrets Manager inside the compliance account boundary.gitleaksruns pre-commit, in CI, and as a weekly full-history sweep.
The RUO-claims guardrail
The RUO vertical sells research-use-only compounds, so store copy must not
make disease, structure-function, dosing, or prescription-drug claims. The
guardrail (packages/guardrails) scans a manifest draft’s entire object tree
for matching terms, classifying findings by category and severity
(CRITICAL for disease/dosing/prescription-drug-name, HIGH for
structure-function), and reports JSON-path findings.
It is enforced at the manifest perimeter in modules/stores:
- Fail-closed on scanner error. If the scan itself throws, the publish is
503 scan_unavailable— “this manifest was NOT verified” — never a silent pass. - Merchants get a hard block, no override. A merchant publish
(
PUT /v1/my/store/manifest) with anyCRITICALfinding is a422 ruo_claims_violationtelling the merchant to contact the operator. There is no merchant-facing bypass — the escape hatch does not exist for them by construction. - Operators can override with a written reason. An operator publish may
proceed past
CRITICALfindings only by supplyingx-guardrails-override: <reason>, and that reason is recorded verbatim in the audit row alongside the findings.HIGHfindings return as warnings, not blocks.
Threats and mitigations
| Threat | Mitigation |
|---|---|
| Cross-tenant data access via a forged path/body tenant | Tenant read from the credential row only; tenant_ref NOT NULL no-default columns |
| Stolen credential replayed | Keys hashed at rest; revocation checked on every verify; per-key rate limit before lookup |
| Privilege escalation by minting a stronger key | Subset-gating; 403 scope_escalation; first key requires an owner identity session |
| Storefront token exfiltrated from a browser | Server-side only, no browser SDK, no CORS; token is a log/Sentry redaction target |
| SSRF / cloud-metadata fetch via a merchant webhook URL | Private-range guard at create and dispatch; single-resolve DNS pinning; prod-hard loopback block |
| Empty or missing secret shipping as “working” | Fail-loud requiredEnv at boot; no empty defaults; no AUTH_DISABLED |
| Non-compliant RUO claims going live | Manifest guardrail: hard 422 for merchants, audited operator-only override, 503 fail-closed on scanner error |
| Uncaught error leaking internals to a client | Single error boundary: internals to log/Sentry only, client sees { error: { code, message, correlationId } } |