Self-Hosted vs Cloud: The Security Headers Gap
Self-Hosted vs Cloud: The Security Headers Gap
We ran the Security Headers & Basics audit against 30 open-source projects. The results split cleanly along one axis that has nothing to do with code quality: deployment model.
Cloud-deployed projects:
- Supabase: 93/A
- Formbricks: 92/A
- PostHog: 88/B
- Dub: 85/B
- Cal.com: 82/B
Self-hosted projects:
- NocoDB: 52/D
- Payload: 45/D
- Immich: 40/D
- Hoppscotch: 39/F
That is a 40-50 point gap between the top cloud projects and the self-hosted group. These are all well-maintained, heavily starred, professionally run open-source projects. The difference is not engineering quality. The difference is responsibility boundaries.
Why self-hosted projects score lower
When Supabase deploys to supabase.com, they control every layer: the application code, the hosting platform, the CDN, the TLS termination. They set Strict-Transport-Security, Content-Security-Policy, X-Frame-Options, and Referrer-Policy in their Next.js config or their Vercel settings. Those headers ship with every response.
When Payload ships a self-hosted CMS, they ship application code. The user runs it behind nginx, Caddy, Traefik, Apache, a cloud load balancer, or Docker Compose on a VPS. The project cannot set transport-level headers because it does not control the transport layer. HSTS in particular is meaningless without TLS termination, which happens at the reverse proxy — not in the application.
This is the right architecture. Payload should not be hardcoding Strict-Transport-Security: max-age=31536000 into responses that might be served over plain HTTP on a local network. But the audit does not know that. It sees missing headers and marks them as failures.
What this means for scores
Our Security Headers audit has four categories, weighted as follows:
- Transport Security (30%): HTTPS enforcement, HSTS, secure cookies, SameSite
- Security Headers (30%): CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, SRI, CORS
- Information Exposure (25%): stack traces, server version, error pages, source maps
- Basic Hygiene (15%): .env in .gitignore, no hardcoded secrets, dependency audit, lockfile, security.txt
Self-hosted projects typically ace Basic Hygiene and Information Exposure. They fail Transport Security and much of Security Headers because those checks assume the application controls its own HTTP responses end-to-end. For a self-hosted project, the Transport Security category is almost entirely the operator's responsibility.
The audit still provides value: it tells the operator exactly what their reverse proxy needs to configure. But the raw score is not an apples-to-apples comparison with cloud-deployed projects.
Fixing it at the reverse proxy
If you are deploying a self-hosted project, here is what you need to add at the reverse proxy layer.
Nginx
server {
listen 443 ssl http2;
server_name your-app.example.com;
# Transport Security
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Security Headers
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Hide server info
server_tokens off;
proxy_hide_header X-Powered-By;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Caddy
Caddy is simpler because it handles TLS automatically:
your-app.example.com {
reverse_proxy localhost:3000
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';"
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
-Server
-X-Powered-By
}
}
Caddy automatically provisions Let's Encrypt certificates and enforces HTTPS by default, which is why it is increasingly popular for self-hosted deployments. The HSTS header tells browsers to remember this and refuse unencrypted connections in the future.
How to interpret self-hosted scores
If you are evaluating a self-hosted project and it scores below 60 on Security Headers, check which categories are failing. If Transport Security and Security Headers are the problem but Basic Hygiene and Information Exposure look solid, the project is probably fine — you just need to configure your reverse proxy.
If Basic Hygiene is failing (hardcoded secrets, missing lockfile, .env not in .gitignore), that is a real problem regardless of deployment model.
The broader lesson
Audit scores are not reputation scores. A 39 does not mean Hoppscotch has bad security. It means that running Hoppscotch with default settings, without a properly configured reverse proxy, leaves you exposed to the specific attacks that security headers prevent: clickjacking, MIME sniffing, protocol downgrade, and cross-site data leakage.
The score measures the state of the deployment, not the quality of the project. For self-hosted software, the deployment is a shared responsibility between the project and the operator. The audit tells you exactly which half you still need to do.