Security Headers
Objectuve has two independent origins, each with its own security-headers mechanism — neither covers the other.
| Origin | Mechanism | Source of truth | Env-driven? |
|---|---|---|---|
api.objectuve.com (Rails API) | SecurityHeaders Rack middleware | rails_api/config/initializers/security_headers.rb | Yes — CSP_REPORT_URI read from ENV at request time |
app.objectuve.com (Vue SPA, Firebase Hosting) | Static headers rules in Firebase Hosting config | firebase.json (enkidu-app / enkidu-app-staging targets) | No — firebase.json has no environment interpolation; the report endpoint is a literal URL committed to the file |
A Content-Security-Policy (or -Report-Only) header only applies to responses sent from the origin that emits it. The Rails middleware runs on every API response, but it cannot put a header on a response the SPA's own Firebase Hosting server sends — that's a structurally different origin. Before OBJ-2097, this meant the SPA — the only surface with user-generated content, third-party script injection (Clerk, Sentry, PostHog), and the Coach chat interface — shipped no CSP of any kind, while the API's report-only policy protected six static ERB templates with zero <script> tags between them. Assume you need to touch both mechanisms if you're changing CSP behavior across the whole product; a change to one never reaches the other.
The rest of this page covers the API's middleware-based headers first, then the SPA's Firebase Hosting headers in their own section below.
Headers Reference (API)
| Header | Value | Scope | Purpose |
|---|---|---|---|
X-Frame-Options | DENY | All environments | Prevents clickjacking by disallowing the site from being embedded in frames |
X-Content-Type-Options | nosniff | All environments | Prevents MIME-sniffing attacks by forcing the browser to respect the declared Content-Type |
Referrer-Policy | strict-origin-when-cross-origin | All environments | Limits referrer information sent to external sites — only send origin on cross-origin requests, never the full URL |
X-Permitted-Cross-Domain-Policies | none | All environments | Disallows Adobe Flash/PDF cross-domain requests |
Permissions-Policy | camera=(), microphone=(), geolocation=() | All environments | Disables browser APIs for camera, microphone, and geolocation; can only be re-enabled by explicit user interaction in the frontend |
Strict-Transport-Security | max-age=31536000; includeSubDomains | Production & Staging only | Forces HTTPS for 1 year (31,536,000 seconds) and applies to all subdomains. Not emitted in local/development environments. |
Content-Security-Policy | See policy below | All environments | Enforcing — violations are blocked by the browser. Applies to the Rails API's own responses only (see scope note below). |
Content Security Policy (Enforcing) — API
Current mode: enforcing. Promoted from Content-Security-Policy-Report-Only on 2026-08-03 (OBJ-486).
Scope — what this policy actually protects
This header is emitted by the Rails API middleware, so it governs only documents served from the API origin (api.objectuve.com / staging.api.objectuve.com). CSP is a directive to a browser about how to load subresources for a parsed document — a JSON GraphQL response is never parsed as a document, so for the overwhelming majority of API traffic this header is inert. The real consumers are the six server-rendered HTML views the API owns:
| View | <script> tags | <style> blocks | External subresources |
|---|---|---|---|
layouts/unsubscribe.html.erb | 0 | 1 | — |
unsubscribes/show.html.erb | 0 | 0 | www.objectuve.com logo |
team_summaries_unsubscribes/show.html.erb | 0 | 0 | www.objectuve.com logo |
public_goals/show.html.erb | 0 | 0 | — |
privacy_requests/new.html.erb | 0 | 1 | — |
privacy_requests/success.html.erb | 0 | 1 | — |
The SPA at app.objectuve.com is not covered by this header — it is served by Firebase Hosting, which emits no CSP at all. That is the larger remaining gap; this middleware cannot close it, because a CSP only applies to responses from the origin that sends it. Closing it means adding a Content-Security-Policy entry to firebase.json's headers block (mind the last-match-wins ordering gotcha documented in CLAUDE.md).
Policy Directives
default-src 'self'
script-src 'self' https://cdn.jsdelivr.net https://*.clerk.accounts.dev
style-src 'self' 'unsafe-inline'
img-src 'self' data: blob: https://storage.googleapis.com https://www.objectuve.com
connect-src 'self' https://*.clerk.accounts.dev https://*.sentry.io
frame-ancestors 'none'Directive Explanation
default-src 'self'— All content types default to same-origin only. Per-type overrides below narrow or expand this.script-src— Inline scripts blocked. Allows same-origin scripts, jsDelivr CDN (for third-party libs), and Clerk's OAuth popups.style-src— Allows same-origin stylesheets and inline styles (required for Tailwind dynamic classes + Ionic framework styles).img-src— Allows same-origin, data URIs, blobs (for client-side image generation), Google Cloud Storage (for user-uploaded images), andhttps://www.objectuve.com(the Objectuve logo embedded by both unsubscribe views).www.objectuve.comis not'self'— CSP'self'matches the exact origin, and a page served fromapi.objectuve.comtreats its own marketing subdomain as third-party. Dropping this source silently blanks the logo on those two pages.connect-src— Allows same-origin API calls, Clerk for OAuth, and Sentry for error reporting and CSP violation reports.frame-ancestors 'none'— Disallows embedding the app in frames (complementsX-Frame-Options: DENY).
Reporting Violations
CSP_REPORT_URIhas never been set in any deployed environment. It is absent fromdeploy/*.yamland every GitHub Actions workflow, there is no matching GCP Secret Manager entry, and the live headers on both production and staging carry noreport-uridirective. The consequence: the report-only period from 2026-05-21 to 2026-08-03 collected zero violation data — browsers had nowhere to POST to. Enforcement was justified by an exhaustive static audit of the six views above (see the scope table), not by telemetry. If you want reporting, provision the env var first and confirm the directive appears in the live header before treating a quiet Sentry as a clean signal.
When CSP_REPORT_URI is configured, the browser automatically reports CSP violations as POST requests to the Sentry endpoint. No code change is needed — the header includes the directive and the browser handles reporting.
To enable violation reporting:
- Set
CSP_REPORT_URIenvironment variable (see Deployment: Observability Environment Variables) - The header is automatically rewritten to include the
report-uridirective - Violations appear in Sentry under the CSP Reports tab
Typical violations you might see:
script-src— inline scripts, third-party scripts outside the allowliststyle-src— inline styles (should be rare with Tailwind's JIT compilation)img-src— images from unexpected origins (e.g., ad networks, social embeds)connect-src— API calls to unexpected origins (e.g., analytics, tracking pixels)
Implementation Details (API)
The middleware is implemented in rails_api/config/initializers/security_headers.rb and is registered as a Rails middleware that intercepts all responses.
Environment-gated headers:
Strict-Transport-Securityis only emitted whenRails.env.local?is false (i.e., only production and staging send it; local development and test environments do not)- All other headers are emitted in all environments
CSP_REPORT_URI is optional:
If CSP_REPORT_URI is not set, the report-uri directive is omitted from the CSP header. The policy is still enforced — only the reporting channel is absent. This is the state of every deployed environment today.
SPA (app.objectuve.com) Content Security Policy
Current mode: Report-only, on both enkidu-app (production) and enkidu-app-staging targets. Shipped under OBJ-2097. Same as the API: violations are reported, nothing is blocked yet.
Why this is a separate mechanism, not a config of the API's
app.objectuve.com is served by Firebase Hosting, not Rails — the middleware above never touches it. Its headers come entirely from the headers block of the enkidu-app / enkidu-app-staging targets in firebase.json, a static file with no environment interpolation. That has one concrete consequence worth internalizing before touching this config: there is no CSP_REPORT_URI equivalent for the SPA. The report endpoint is a literal URL committed directly into firebase.json, not read from an env var at request/build time.
That's safe here because the value being hardcoded is a Sentry security-report endpoint built from the frontend's own public DSN — the same DSN that already ships inside the JS bundle and is hardcoded in .github/workflows/production.yml. It carries no secret. Hardcoding it is also a better failure mode than the env var it replaces: the value is visible in code review, so it can't be silently unset the way CSP_REPORT_URI was for the API (see the OBJ-473 note below) — there's no missing-env-var state for it to fall into.
Policy directives (current, both targets)
The two targets share every directive except connect-src and report-uri's sentry_environment param, which intentionally diverge per environment — each target's connect-src allowlists only its own API host, never the other's. This was not always true: from OBJ-2097 (2026-08-03) until OBJ-2700 (2026-08-18), enkidu-app-staging's connect-src was byte-identical to production's, allowlisting https://api.objectuve.com instead of https://staging.api.objectuve.com — a copy-paste artifact from authoring both targets off one directive string. It generated 371,873 false-positive violation reports (744 users) before being caught; see scripts/check-firebase-csp.mjs's 6th check below, added specifically to make this class of drift impossible to reintroduce silently.
That fix is real and it holds — but it was not the only source of a staging.api.objectuve.com-blocked-by-connect-src report, and conflating the two read as a single still-open incident during the v4.41 report-only window. Partitioning traffic at that window's kickoff (2026-08-20) found the enkidu-app-staging target itself clean — zero reports naming a mismatched host from that target. Every live report in that shape instead had a Document URI on a PR preview channel (e.g. https://enkidu-app--pr-2523-948cu6gd.web.app/…). Firebase Hosting preview channels are per-site, and at the time this repo's previews deployed to the enkidu-app site — so a preview channel inherited the production target's headers config verbatim (connect-src … https://api.objectuve.com, report-uri … sentry_environment=production) with no per-channel override, while the preview build itself called staging.api.objectuve.com. check-firebase-csp.mjs's 6th check couldn't see this: it asserts each target's connect-src, and a preview channel is not a target.
This is fixed. v4.41 Phase 2 (OBJ-2791, PR #2534, merged 2026-08-20) moved PR preview channels to the enkidu-app-staging site (.github/workflows/preview.yml's PREVIEW_HOSTING_TARGET: enkidu-app-staging), so a preview channel now inherits staging's policy — the one whose connect-src actually matches what the preview build calls. The same PR added evaluatePreviewChannelApiHost to scripts/check-firebase-csp.mjs, invoked from preview.yml's deploy job with the job's own PREVIEW_HOSTING_TARGET/PREVIEW_API_HOST, as a channel-aware check the target-scoped 6th check structurally can't be.
Production (enkidu-app):
default-src 'self'
script-src 'self' 'sha256-EKHzCu8rVyZkvJfhbR9YTL3GSQ7+EdT9vCbMBJItsv8=' https://clerk.objectuve.com https://*.clerk.accounts.dev https://challenges.cloudflare.com https://*.protect.clerk.com https://us-assets.i.posthog.com
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com
font-src 'self' https://fonts.gstatic.com
img-src 'self' data: blob: https://storage.googleapis.com https://ui-avatars.com https://img.clerk.com
connect-src 'self' https://api.objectuve.com wss://api.objectuve.com https://clerk.objectuve.com https://*.clerk.accounts.dev https://*.protect.clerk.com https://*.ingest.us.sentry.io https://us.i.posthog.com https://us-assets.i.posthog.com https://fonts.googleapis.com https://fonts.gstatic.com blob:
worker-src 'self' blob:
frame-src https://challenges.cloudflare.com https://*.protect.clerk.com
frame-ancestors 'none'
base-uri 'self'
form-action 'self'
object-src 'none'
report-uri https://o31723.ingest.us.sentry.io/api/1270622/security/?sentry_key=a3166ce23ff6427fa37e5e480c9ba0a2&sentry_environment=productionStaging (enkidu-app-staging) — identical to production above except the connect-src API host and report-uri's sentry_environment param:
default-src 'self'
script-src 'self' 'sha256-EKHzCu8rVyZkvJfhbR9YTL3GSQ7+EdT9vCbMBJItsv8=' https://clerk.objectuve.com https://*.clerk.accounts.dev https://challenges.cloudflare.com https://*.protect.clerk.com https://us-assets.i.posthog.com
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com
font-src 'self' https://fonts.gstatic.com
img-src 'self' data: blob: https://storage.googleapis.com https://ui-avatars.com https://img.clerk.com
connect-src 'self' https://staging.api.objectuve.com wss://staging.api.objectuve.com https://clerk.objectuve.com https://*.clerk.accounts.dev https://*.protect.clerk.com https://*.ingest.us.sentry.io https://us.i.posthog.com https://us-assets.i.posthog.com https://fonts.googleapis.com https://fonts.gstatic.com blob:
worker-src 'self' blob:
frame-src https://challenges.cloudflare.com https://*.protect.clerk.com
frame-ancestors 'none'
base-uri 'self'
form-action 'self'
object-src 'none'
report-uri https://o31723.ingest.us.sentry.io/api/1270622/security/?sentry_key=a3166ce23ff6427fa37e5e480c9ba0a2&sentry_environment=stagingconnect-src's https://fonts.googleapis.com, https://fonts.gstatic.com, and blob: were added under OBJ-2790 (v4.41 Phase 1, PR #2533): the report-only window showed the service worker's Google Fonts caching (Document URI: /sw.js, Source File: /workbox-*.js — a fetch() from inside a service worker is connect-src regardless of what the fetched resource is for) and the Sentry SDK's blob: worker fetch (/assets/vendor-sentry-*.js) both firing as violations. script-src's https://us-assets.i.posthog.com was added earlier, under OBJ-2121 (2026-08-06).
This is not a deliberately-loose starting policy — it's the policy the team would actually want to enforce, minus one concession kept only because the report-only window needs to observe it: style-src 'unsafe-inline' (Ionic + Tailwind + Clerk's runtime CSS-in-JS). script-src carries no 'unsafe-inline' — Firebase Hosting can't serve per-request nonces, so the one inline <script> a production build ships (the launch-splash reveal animation in ionic_frontend/index.html, added under OBJ-2802) is allowlisted by a sha256- source expression instead — see "The launch-splash inline script's CSP hash" below. Every allowlisted origin traces to a real production consumer (Clerk auth/Turnstile/fraud-protection, Sentry error+CSP reporting, PostHog analytics incl. session-recording worker, Google Fonts, the API, and GCS-backed user images) — see PR #2077's body for the full origin-to-evidence table.
The launch-splash inline script's CSP hash
ionic_frontend/index.html ships exactly one inline <script> — the launch-splash reveal animation added under OBJ-2802 (a from-scratch hand-port of ObjectuveLogo.vue's animate="reveal" sweep; this file paints before /src/main.ts loads, so it can't import the real component). Both SPA targets' script-src allowlist its exact text content via a sha256- source expression rather than 'unsafe-inline'.
Maintenance rule: editing that script's text — including whitespace — changes its SHA-256 hash. If you touch it, recompute the hash and update firebase.json's script-src on both enkidu-app and enkidu-app-staging in the same commit, or scripts/check-firebase-csp.mjs's evaluateInlineScriptCspHash check fails the lint-workflows CI job.
How to recompute — verify against ground truth, not against yourself: this lesson cost three failed fix rounds and a reviewer, so two independent derivations must agree before you trust a hash:
computeInlineScriptCspHash()inscripts/check-firebase-csp.mjs— extracts the script's exact text via a comment/<style>-aware raw-text scan that mirrors the HTML tokenizer's raw-text content model (a plain regex match here has twice produced a wrong-but-internally-consistent hash), then hashes it.- The independent Python
html.parserderivation documented in a comment directly above the pinned-hash test inscripts/check-firebase-csp.test.mjs— a from-scratch reference implementation sharing no code with (1).
Both must produce the same sha256-… value, and that value must appear verbatim in firebase.json's script-src on both targets — computeInlineScriptCspHash()'s own output agreeing with itself is not sufficient evidence the hash is correct.
Header only — never <meta http-equiv>
ionic_frontend/dist is the exact bundle Capacitor ships to iOS and Android, where origins are different (capacitor://, plus the app's local https://app.objectuve.com server per capacitor.config.ts, plus Custom Tab OAuth). A Firebase Hosting response header cannot reach the native WebView — which is why native is unaffected by this policy at all, by construction. A <meta http-equiv="Content-Security-Policy"> tag in index.html, by contrast, would ship into the native bundle and apply origins that don't make sense there. Rule: CSP for the SPA must only ever be a Firebase Hosting response header, never a meta tag. scripts/check-firebase-csp.mjs asserts ionic_frontend/index.html carries no such tag, so this can't regress silently.
The Firebase Hosting header-ordering constraint
Firebase Hosting headers rules are last-match-wins per header key — the opposite of rewrites/redirects, which are first-match-wins (see CLAUDE.md's Firebase-headers gotcha for the full mechanics and the OBJ-1500 incident it's rooted in). The CSP line was added to the existing first "**" security-headers rule on each SPA target rather than as a new rule object, specifically so it can't collide with or reorder anything — Content-Security-Policy-Report-Only is a header key no other rule in either target sets. The rule order that must survive any future edit to this file, on both enkidu-app and enkidu-app-staging:
security-headers "**" → no-cache "**" → immutable "assets/**" → sw.js no-cache → apple-app-site-associationIf the broad no-cache "**" rule ever ends up after the assets/** immutable-cache rule, hashed assets silently lose their long-lived cache — this is the exact shape of the OBJ-1500 incident. scripts/check-firebase-csp.mjs (wired into ci.yml's lint-workflows job) asserts this order on every PR; the accepted tradeoff of keeping the CSP line inside the shared "**" rule is that it also attaches to hashed asset responses (~600 bytes of pure overhead per asset, since CSP has no meaning on those responses) — deliberate, to avoid reintroducing the ordering risk a dedicated rule would carry.
Reporting violations and the observation-window checkpoint
This is the section that exists to prevent a repeat of OBJ-473. OBJ-473 shipped the API's report-only CSP with a report-only window that ran 2026-05-21 to 2026-08-03 (74 days) and collected zero violations — not because the policy was airtight, but because CSP_REPORT_URI was never actually provisioned, so browsers had nowhere to send reports. A quiet Sentry was wrongly read as a clean signal for over two months.
Two independent things have to both be true for a quiet Sentry to mean anything here:
- The pipe has to be proven live, not just configured. Presence of
report-uriin the header (verifiable withcurl -sI) proves the string is there — it proves nothing about whether Sentry is listening on the other end. The verification that actually closes this gap is a deliberately-triggered violation (e.g. an out-of-allowlist script/image injected via devtools against a live deploy) confirmed to arrive in Sentry's Security Headers view, with evidence attached to OBJ-2097's issue thread. Until that round-trip has been demonstrated at least once, treat a violation-free Sentry as unverified, not clean. - Someone has to be scheduled to look. OBJ-2133 ("Evaluate SPA CSP report-only window and promote to enforcing") is the dated checkpoint artifact: due 2026-08-17 (14 days from OBJ-2097 landing), currently in
backlogpending an owner assignment at promotion time. Its own first acceptance criterion is re-verifying the Sentry round-trip is still live — not just checking the violation count — for the same reason: a zero-violation window means nothing until the pipe is proven live a second time.
If OBJ-2133 comes due with the round-trip never demonstrated, that is itself the finding — extend the window and fix the pipe before reading anything into the violation count.
The guard script
scripts/check-firebase-csp.mjs (.github/workflows/ci.yml's lint-workflows job, paired with scripts/check-firebase-csp.test.mjs) locks in properties of firebase.json so a future edit can't regress them silently:
- Both
enkidu-appandenkidu-app-stagingsetContent-Security-Policy-Report-Only. - Neither target sets that header key on more than one rule (the last-match-wins trap above).
- The directive string carries a non-empty
report-uri— the exact check that would have caught OBJ-473. - The
"**"no-cache rule still precedes theassets/**immutable-cache rule. - Neither target emits an enforcing
Content-Security-Policykey — a tripwire so enforcement can only ever land as a deliberate, guard-updating change, never a drive-by edit. - Each target's
connect-srcallowlists its own environment's API host over bothhttpsandwss(OBJ-2862 — CSP scheme matching is asymmetric, so anhttps://hostsource expression does not also authorize that same host'swsscounterpart) and not the other target's over either scheme — added under OBJ-2700, afterenkidu-app-stagingshipped allowlisting production's API host undetected for 15 days (see the divergence note above). This check is scoped to the twofirebase.jsontargets — it cannot see the PR-preview-channel population described above. That gap is closed, not open:evaluatePreviewChannelApiHost(see above) covers it, added under v4.41 Phase 2 (OBJ-2791). - Each target's
connect-srccarrieshttps://fonts.googleapis.com,https://fonts.gstatic.com, andblob:— added under OBJ-2790 (v4.41 Phase 1), so the service-worker font fetches and the Sentry SDK'sblob:fetch can't be silently dropped from the allowlist.
An eighth, non-target-specific check confirms ionic_frontend/index.html carries no <meta http-equiv="Content-Security-Policy"> tag.
A ninth check, evaluateInlineScriptCspHash (OBJ-2802), recomputes the launch-splash inline <script>'s sha256- hash from ionic_frontend/index.html's current contents and asserts that exact value is present in both targets' script-src — the check that fails if the script and firebase.json drift apart. See "The launch-splash inline script's CSP hash" above.
Next Steps
- API — tighten
style-src— Replace'unsafe-inline'with a hash-based or nonce-based approach if Tailwind and Ionic can be configured to generate hashed styles instead of inline. - API — provision
CSP_REPORT_URI— optional, but without it there is no signal when a future view introduces a blocked subresource. Under enforcement that now shows up as a broken page rather than a log line. - SPA — evaluate the report-only window and promote to enforcing — tracked in OBJ-2133, due 2026-08-17. Do not treat this as a static-audit decision the way the API's promotion could be — enforcing a wrong policy on the SPA breaks the app for every user, so it needs the observation window's real violation data first.
See the Security Headers middleware and firebase.json for the two implementations.
Last updated: 2026-08-23