Last Updated: July 12, 2026
PinPoint implements a defense-in-depth security strategy using HTTP security headers to protect against common web vulnerabilities. This document describes the security headers configuration, implementation details, and modification guidelines.
The following headers are set statically for all routes via next.config.ts:
| Header | Value | Purpose |
|---|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Enforces HTTPS connections for 2 years, including all subdomains |
X-Frame-Options |
DENY |
Prevents clickjacking by blocking iframe embedding |
X-Content-Type-Options |
nosniff |
Prevents MIME-sniffing attacks |
Referrer-Policy |
strict-origin-when-cross-origin |
Controls referrer information sent with requests |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
Disables unnecessary browser features |
Content-Security-Policy (CSP) is set dynamically in middleware to support nonce-based script execution:
| Directive | Value | Purpose |
|---|---|---|
default-src |
'self' |
Default policy: only load resources from same origin |
script-src |
'self' 'nonce-{random}' 'strict-dynamic' |
Scripts: same origin or with valid nonce. 'strict-dynamic' allows dynamically loaded scripts |
style-src |
'self' 'unsafe-inline' |
Styles: same origin or inline (for CSS-in-JS compatibility) |
img-src |
'self' data: blob: https://*.public.blob.vercel-storage.com |
Images: same origin, data URIs, blob URLs, or Vercel Blob CDN (uploaded avatars) |
font-src |
'self' data: |
Fonts: same origin or data URIs |
connect-src |
'self' {supabase-url} {supabase-ws-url} localhost |
Network requests: same origin, Supabase, and local development |
object-src |
'none' |
No plugins (Flash, Java, etc.) |
base-uri |
'self' |
Restricts <base> tag URLs |
form-action |
'self' |
Forms can only submit to same origin |
frame-ancestors |
'none' |
Cannot be embedded in any frame (stricter than X-Frame-Options) |
block-all-mixed-content |
- | Blocks HTTP content on HTTPS pages |
upgrade-insecure-requests |
- | Upgrades HTTP requests to HTTPS |
PinPoint uses nonce-based Content-Security-Policy instead of 'unsafe-inline' and 'unsafe-eval' for enhanced security:
- Nonce Generation: A unique cryptographic nonce (UUID) is generated for each request in
middleware.ts - Header Injection: The nonce is injected into the CSP header as
'nonce-{uuid}' - x-nonce Header: The nonce is exposed via
x-nonceresponse header for use in Server Components - Script Execution: Only scripts with matching nonce attributes can execute
Example Script Tag (future implementation):
// In a Server Component
export default async function Page() {
const nonce = headers().get("x-nonce");
return (
<Script nonce={nonce}>console.log('This script will execute');</Script>
);
}The CSP is configured to work with Supabase authentication and real-time features:
- HTTPS:
https://{project-id}.supabase.cofor API requests - WebSocket:
wss://{project-id}.supabase.cofor Realtime subscriptions - No Wildcards: Uses specific project URL from
NEXT_PUBLIC_SUPABASE_URLinstead of*.supabase.co
CSP allows local connections for development:
http://127.0.0.1:*andws://127.0.0.1:*http://localhost:*andws://localhost:*
The CSP currently includes 'unsafe-inline' for style-src to maintain compatibility with:
- CSS-in-JS libraries (if used in future)
- Inline styles in third-party components
- Server-rendered styles from Next.js
Future Improvement: Once nonce support is tested with all components, migrate to nonce-based styles:
style-src 'self' 'nonce-{random}';
The 'strict-dynamic' directive allows scripts loaded by nonce'd scripts to execute without their own nonce. This is necessary for:
- Next.js dynamic imports
- Third-party scripts loaded by trusted code
- Client-side routing and code splitting
Trade-off: Slightly relaxes CSP but required for modern JavaScript frameworks. The initial script must still have a valid nonce.
If you need to add external resources (CDNs, analytics, etc.), update the appropriate directive in middleware.ts:
Example: Adding Google Fonts
const cspHeader = `
...
font-src 'self' data: https://fonts.gstatic.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
...
`;Example: Adding Vercel Analytics
const cspHeader = `
...
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://va.vercel-scripts.com;
connect-src 'self' ${supabaseUrl} ${supabaseWsUrl} https://va.vercel-scripts.com;
...
`;- Check Browser Console: CSP violations appear as console errors
- Review Network Tab: Blocked requests show "blocked:csp" status
- Test User Flows: Verify auth, forms, and dynamic features work
- Use CSP Evaluator: https://csp-evaluator.withgoogle.com/
Prerequisites:
- Deployment to staging environment (e.g., Vercel preview)
- Browser DevTools (Chrome, Firefox, or Safari)
Test Procedure:
- Open DevTools → Navigate to Network tab
- Load the application → Navigate to any page
- Inspect Response Headers:
- Click on the document request (first item in Network tab)
- Go to Headers tab
- Scroll to Response Headers section
Verify Headers Present:
✅ strict-transport-security: max-age=63072000; includeSubDomains; preload
✅ x-frame-options: DENY
✅ x-content-type-options: nosniff
✅ referrer-policy: strict-origin-when-cross-origin
✅ permissions-policy: camera=(), microphone=(), geolocation=()
✅ content-security-policy: default-src 'self'; script-src 'self' 'nonce-...' 'strict-dynamic'; ...
✅ x-nonce: <uuid>
Verify CSP Directives:
Check that content-security-policy contains:
default-src 'self'script-src 'self' 'nonce-<random-uuid>' 'strict-dynamic'style-src 'self' 'unsafe-inline'img-src 'self' data: blob: https://*.public.blob.vercel-storage.comconnect-src 'self' https://<project>.supabase.co wss://<project>.supabase.coobject-src 'none'frame-ancestors 'none'
Verify Nonce:
-
Copy nonce value from
x-nonceheader (e.g.,a1b2c3d4-e5f6-...) -
Open Console tab
-
Run test script:
// This should FAIL (no nonce) const scriptFail = document.createElement("script"); scriptFail.textContent = 'console.log("BLOCKED");'; document.body.appendChild(scriptFail); // Check console for CSP violation error
-
Expected Result: Console shows CSP violation error
Verify User Flows Work:
- ✅ Authentication (login/logout)
- ✅ Form submissions
- ✅ Navigation between pages
- ✅ Supabase realtime connections (if implemented)
Common Issues:
| Symptom | Likely Cause | Fix |
|---|---|---|
| CSP header missing | Middleware not running | Check middleware matcher config |
| Nonce header missing | Middleware error | Check server logs |
| White screen | CSP blocking scripts | Check console for violations |
| Supabase connection fails | Wrong connect-src URL | Verify NEXT_PUBLIC_SUPABASE_URL |
To monitor CSP violations in production, add a report-uri or report-to directive:
const cspHeader = `
...
report-uri /api/csp-report;
`;Then implement the endpoint to log violations.
- Never use 'unsafe-eval': Allows arbitrary code execution
- Minimize 'unsafe-inline': Use nonces or hashes instead
- Avoid wildcards: Be specific with allowed origins
- Test thoroughly: CSP can break legitimate functionality
- Monitor violations: Set up CSP reporting in production
- Keep updated: Review and tighten CSP as codebase evolves
PinPoint's four auth actions (login, signup, forgot-password, reset-password)
verify a Cloudflare Turnstile token fail-open in verifyTurnstileFailOpen
(src/lib/security/turnstile.ts): the gate always allows the submission through,
even when the token is missing, rejected, or unverifiable. This is an intentional,
Tim-signed-off tradeoff (PP-20yy): availability > strict captcha enforcement,
because a fail-closed gate repeatedly locked real users out when the Turnstile
widget failed transiently. IP + account rate limiting is the real abuse backstop;
captcha here is best-effort deterrence.
Because PinPoint verifies Turnstile itself (fail-open) instead of delegating to Supabase's built-in captcha protection, Supabase Auth captcha protection must remain disabled for the project. If it were enabled, Supabase would reject the tokenless submissions the fail-open gate is designed to let through — upstream of our check — silently breaking auth for every user whose widget fails, and it would double-spend the single-use token we already consume.
This precondition is enforced only by convention today (a code comment in
verifyTurnstileFailOpen and a NOTE at each call site in
src/app/(auth)/actions.ts); there is no runtime assertion. A startup
assertion was deliberately deferred (PP-vo43) because reading the setting requires
a Supabase Management API call on the boot path, which could break startup. The
durable enforcement is the documented invariant plus a manual dashboard check —
see the monitoring runbook: docs/runbooks/turnstile-fail-open-monitoring.md.
Every fail-open path emits a structured log line (event: "turnstile_fail_open")
and a Sentry message (turnstile.fail_open, tagged with action and
turnstileOutcome). A sustained spike may indicate bot abuse exploiting the
weakened gate (or a Turnstile outage). The alert threshold (> 50/hour → security
channel) and the spike action plan (triage by outcome → tighten rate limits →
last-resort re-enable Supabase captcha + fail-closed gate in tandem) are documented
in docs/runbooks/turnstile-fail-open-monitoring.md. Creating the Sentry alert
rule and verifying the live Supabase setting are manual dashboard actions tracked
there as follow-ups.
- XSS (Cross-Site Scripting): Nonce-based CSP prevents unauthorized script execution
- Clickjacking: X-Frame-Options and frame-ancestors prevent iframe embedding
- MIME Sniffing: X-Content-Type-Options prevents content type confusion
- Protocol Downgrade: HSTS enforces HTTPS
- Mixed Content: CSP blocks HTTP resources on HTTPS pages
- CSRF: Requires additional token-based protection (not yet implemented)
- SQL Injection: Prevented by Drizzle ORM parameterization (separate concern)
- Abuse on non-auth surfaces: Rate limiting is not comprehensive across the
app. Auth actions are protected — IP + account rate limiting via
~/lib/rate-limit, plus the best-effort fail-open Turnstile gate (see "Authentication CAPTCHA (Turnstile fail-open)" above) — but other surfaces are not comprehensively rate-limited. - DDoS: Requires infrastructure-level protection
middleware.ts- CSP implementationnext.config.ts- Static security headersdocs/NON_NEGOTIABLES.md- Security requirementsdocs/TECH_SPEC.md- Architecture security considerations