Security6 min read

How I Hardened My Web App's Security Headers Using Nuclei and Automated Scanning

A practical walkthrough of scanning, finding, and fixing HTTP security misconfigurations on Nginx and Vercel

Liban Abdullahi·
368 views
How I Hardened My Web App's Security Headers Using Nuclei and Automated Scanning

You've deployed your app. SSL is green. Authentication works. Everything looks secure — until you actually scan it.

I run a development studio with a Next.js frontend behind Nginx on EC2 and a marketing landing page on Vercel. Both were serving traffic over HTTPS with valid certificates. But when I pointed an automated Nuclei scanner at my own domains, it came back with 54 findings. No critical vulnerabilities, but a stack of missing security headers and information leaks that any attacker could use for reconnaissance.

In this article, I'll walk through the exact findings, why they matter, and the specific configuration changes I made to fix them. If you're running a similar stack, you can apply these fixes in a single deployment.

The Setup: Automated Scanning with Nuclei and n8n

Before diving into findings, here's how the scan ran. I have an n8n workflow on my homelab that triggers Nuclei scans against my domains on a schedule. Nuclei is an open-source vulnerability scanner that uses YAML templates to check for thousands of known misconfigurations, CVEs, and information disclosures.

The scan targeted two domains:

  • studio.example.com — Next.js + Django behind Nginx on EC2
  • example.com — Next.js landing page on Vercel

The results came back as JSONL — one JSON object per finding. 54 total, all classified as "Info" severity. No critical or high findings, which is good. But "Info" doesn't mean "ignore."

What the Scan Found

The findings fell into four categories:

1. Missing HTTP Security Headers (Highest Impact)

Both domains were missing several defense-in-depth headers. The Nginx server had only four security headers configured:

# What I had before
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;

Missing entirely: Content-Security-Policy, Permissions-Policy, Referrer-Policy, Cross-Origin-Resource-Policy, Cross-Origin-Embedder-Policy, Cross-Origin-Opener-Policy, and X-Permitted-Cross-Domain-Policies.

2. Nginx Version Disclosure

The Server header was broadcasting nginx/1.29.4. This gives attackers a specific version to search for CVEs against.

3. Deprecated X-XSS-Protection Header

The X-XSS-Protection: 1; mode=block header is not just outdated — it can actually introduce XSS vulnerabilities in older browsers. Modern browsers have removed the XSS auditor entirely. The correct value is 0, and the real protection comes from Content-Security-Policy.

4. SSH and DNS Issues

The scan also flagged SHA-1 HMAC algorithms enabled on SSH, an outdated OpenSSH version, a weak DMARC policy (p=none), and missing DNSSEC. These are server-level and DNS-level fixes, not application config.

Fixing the Nginx Configuration

Hiding the Version Number

In nginx.conf, one line inside the http block:

http {
    server_tokens off;
    # ... rest of config
}

Before: Server: nginx/1.29.4 After: Server: nginx

Adding Security Headers

In the Nginx server block (default.conf), I replaced the old four headers with a comprehensive set:

server {
    listen 443 ssl http2;
    server_name studio.example.com;

    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "0" always;

    # Content Security Policy
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://studio.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;

    # Permissions Policy
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;

    # Referrer Policy
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Cross-Origin Policies
    add_header Cross-Origin-Resource-Policy "same-origin" always;
    add_header Cross-Origin-Embedder-Policy "require-corp" always;
    add_header Cross-Origin-Opener-Policy "same-origin" always;

    # Block Flash/PDF cross-domain policies
    add_header X-Permitted-Cross-Domain-Policies "none" always;

    # ... rest of server config
}

Key changes from the original:

  • X-Frame-Options changed from SAMEORIGIN to DENY — the app doesn't need to be framed anywhere
  • X-XSS-Protection changed from 1; mode=block to 0 — deprecated header disabled
  • HSTS added preload directive for HSTS preload list eligibility
  • Seven new headers added for CSP, permissions, referrer control, and cross-origin isolation

Fixing the Vercel Landing Page

For the Next.js site on Vercel, security headers go in next.config.ts:

const securityHeaders = [
  { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "X-Frame-Options", value: "DENY" },
  { key: "X-XSS-Protection", value: "0" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" },
  {
    key: "Content-Security-Policy",
    value: [
      "default-src 'self'",
      "base-uri 'self'",
      "img-src 'self' https: data:",
      "style-src 'self' 'unsafe-inline'",
      "font-src 'self' https: data:",
      "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
      "connect-src 'self' https://studio.example.com",
      "frame-ancestors 'none'",
      "object-src 'none'",
      "form-action 'self'",
    ].join("; "),
  },
  { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
  { key: "Cross-Origin-Resource-Policy", value: "same-origin" },
  { key: "X-Permitted-Cross-Domain-Policies", value: "none" },
];

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: securityHeaders,
      },
    ];
  },
};

One thing to note with Vercel: if your apex domain (example.com) redirects to www.example.com, the custom headers only apply on the final destination, not on the 308 redirect response. This is expected behavior.

What Each Header Actually Does

If you're adding these headers, you should understand what they protect against:

HeaderWhat It Prevents
Content-Security-PolicyXSS attacks, code injection, data exfiltration. Defines exactly which sources can load scripts, styles, images, etc.
Permissions-PolicyBlocks browser APIs you don't use (camera, microphone, geolocation) so compromised scripts can't access them.
Referrer-PolicyControls how much URL information leaks when users click links to external sites. Prevents token/path leakage.
Cross-Origin-Resource-PolicyPrevents other sites from loading your resources, protecting against data theft.
Cross-Origin-Embedder-PolicyRequired for SharedArrayBuffer and cross-origin isolation. Mitigates Spectre-type side-channel attacks.
Cross-Origin-Opener-PolicyPrevents other sites from gaining a reference to your window via popups.
X-Permitted-Cross-Domain-PoliciesBlocks legacy Flash and PDF plugins from making cross-domain requests.

Verification

After deploying, a quick curl confirms everything is in place:

curl -I https://studio.example.com 2>/dev/null | grep -iE "content-security|permissions|referrer|cross-origin|x-xss|server|x-frame"
server: nginx
x-frame-options: DENY
x-xss-protection: 0
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline' ...
permissions-policy: camera=(), microphone=(), geolocation=(), payment=() ...
referrer-policy: strict-origin-when-cross-origin
cross-origin-resource-policy: same-origin
cross-origin-embedder-policy: require-corp
cross-origin-opener-policy: same-origin

No version number in the Server header. All security headers present. The X-XSS-Protection correctly set to 0.

Hardening robots.txt

While fixing headers, I also tightened the robots.txt files. The studio app was allowing crawlers to index everything including /api/, /admin/, and /django-admin/. The fix:

User-agent: *
Allow: /
Disallow: /api/
Disallow: /admin/
Disallow: /django-admin/
Disallow: /_next/
Disallow: /static/admin/

For the landing page, I went further and blocked AI training bots (GPTBot, CCBot, anthropic-ai, Google-Extended, Bytespider) and aggressive SEO crawlers (AhrefsBot, SemrushBot, MJ12bot) while keeping Google and Bing access to public pages.

What's Left

Not everything can be fixed in application config:

  • SSH hardening — Disable SHA-1 HMAC algorithms and update OpenSSH on the server
  • DMARC policy — Gradually tighten from p=none to p=quarantine to p=reject after monitoring reports
  • DNSSEC — Enable at the domain registrar level

These are operational tasks that require direct server access and DNS management, not code deployments.

Key Takeaways

  1. Scan your own infrastructure regularly. Automated tools like Nuclei find things you'd never check manually. Set up a recurring scan with n8n or cron.

  2. "Info" severity doesn't mean harmless. Missing security headers are classified as informational, but they're the difference between a hardened app and one that's easy to attack.

  3. The deprecated X-XSS-Protection header can make things worse. If you still have 1; mode=block, change it to 0 or remove it. Use Content-Security-Policy instead.

  4. Hide version numbers. server_tokens off in Nginx is one line that removes a free reconnaissance data point for attackers.

  5. Different platforms need different approaches. Nginx uses add_header directives. Vercel/Next.js uses the headers() function in config. The headers themselves are the same.

  6. Block what you don't need. Permissions-Policy disabling unused browser APIs, robots.txt blocking unnecessary crawlers — both reduce your attack surface with minimal effort.

Liban Abdullahi is the founder of CraftRipple Studio, a Brussels-based development studio specialising in secure full-stack web applications.

Work with CraftRipple Studio if you need security built into your application from day one.

Tags

#Nginx#Security#Nuclei#Vercel#DevOps#http-headers