Your code can be perfectly secure and still get compromised by a misconfigured server, an exposed secret, or an outdated dependency. This lesson covers the operational side of web security — the things that protect your application once it leaves your editor.
HTTPS Everywhere
HTTPS encrypts all traffic between the browser and your server. Without it, attackers on the network can read passwords, session cookies, and API responses in plaintext.
// Next.js middleware to redirect HTTP to HTTPS in production
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const proto = request.headers.get("x-forwarded-proto");
if (proto === "http") {
const httpsUrl = request.url.replace("http://", "https://");
return NextResponse.redirect(httpsUrl, 301);
}
return NextResponse.next();
}On Vercel, Netlify, and most cloud platforms, HTTPS is automatic. For self-hosted servers, use Let's Encrypt with Certbot:
# Install Certbot and get a free TLS certificate
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Auto-renewal is configured automatically. Verify with:
sudo certbot renew --dry-runSecurity Headers
HTTP response headers tell the browser how to behave. A handful of headers prevent entire categories of attacks.
// Next.js next.config.js — set security headers for all routes
const securityHeaders = [
{
// Prevent clickjacking: disallow your site from being framed
key: "X-Frame-Options",
value: "DENY",
},
{
// Prevent MIME-type sniffing
key: "X-Content-Type-Options",
value: "nosniff",
},
{
// Control how much referrer info is sent
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
// Force HTTPS for the next year (including subdomains)
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains; preload",
},
{
// Restrict browser features your site does not use
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=()",
},
{
// Content Security Policy
key: "Content-Security-Policy",
value: [
"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'",
"base-uri 'self'",
"form-action 'self'",
].join("; "),
},
];
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};
export default nextConfig;What Each Header Does
| Header | Purpose |
|---|---|
Strict-Transport-Security | Forces HTTPS, prevents SSL stripping attacks |
X-Frame-Options | Prevents clickjacking by blocking iframes |
X-Content-Type-Options | Stops browsers from MIME-sniffing responses |
Referrer-Policy | Controls how much URL info leaks to other sites |
Permissions-Policy | Disables browser APIs you do not need |
Content-Security-Policy | Controls which resources the browser can load |
You can verify your headers at securityheaders.com.
Environment Variables
Secrets must never appear in source code, client-side bundles, or version control.
// WRONG: hardcoded secret
const stripe = new Stripe("sk_live_abc123");
// CORRECT: environment variable
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);Rules for Environment Variables
# .env.local — local development secrets (NEVER commit this)
DATABASE_URL=postgres://user:password@localhost:5432/mydb
STRIPE_SECRET_KEY=sk_test_abc123
JWT_SECRET=a-random-string-at-least-32-bytes
# .env.example — committed to git, documents required variables (no real values)
DATABASE_URL=postgres://user:password@host:5432/dbname
STRIPE_SECRET_KEY=sk_test_...
JWT_SECRET=generate-a-random-string# .gitignore — ensure secrets are never committed
.env
.env.local
.env.*.localIn Next.js, only variables prefixed with NEXT_PUBLIC_ are exposed to the browser. Everything else stays server-side.
// Server-only: not in the client bundle
const dbUrl = process.env.DATABASE_URL;
// Client-accessible: included in the JS bundle sent to the browser
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
// NEVER put secrets in NEXT_PUBLIC_ variables
Validate at Startup
Fail fast if required secrets are missing:
// lib/env.ts — validate environment variables at startup
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(
`Missing required environment variable: ${name}. ` +
`Check .env.example for the list of required variables.`
);
}
return value;
}
export const env = {
databaseUrl: requireEnv("DATABASE_URL"),
jwtSecret: requireEnv("JWT_SECRET"),
stripeKey: requireEnv("STRIPE_SECRET_KEY"),
};Dependency Auditing
Your application inherits every vulnerability in its dependencies. A single compromised package can give an attacker access to your server.
# Check for known vulnerabilities in your dependencies
pnpm audit
# Fix automatically where possible
pnpm audit --fix
# Check for outdated packages
pnpm outdatedAutomated Dependency Updates
# Enable Dependabot or Renovate in your repository
# GitHub Dependabot: create .github/dependabot.yml
# Example .github/dependabot.yml# .github/dependabot.yml content:
# version: 2
# updates:
# - package-ecosystem: "npm"
# directory: "/"
# schedule:
# interval: "weekly"
# open-pull-requests-limit: 10Lock File Integrity
# Always commit your lock file (pnpm-lock.yaml, package-lock.json)
# It pins exact versions and prevents supply chain attacks via version ranges
# In CI, use frozen lockfile installs to catch drift
pnpm install --frozen-lockfileChecking for Specific CVEs
# Search for a specific vulnerability
pnpm audit --json | jq '.advisories'
# Check if a specific package has known issues
npx is-my-node-vulnerableMonitoring and Logging
Security does not end at deployment. You need to detect breaches and respond quickly.
// Structured logging for security events
function logSecurityEvent(event: {
type: "auth_failure" | "rate_limit" | "forbidden" | "suspicious_input";
ip: string;
path: string;
details: string;
}) {
console.log(
JSON.stringify({
timestamp: new Date().toISOString(),
level: "warn",
category: "security",
...event,
})
);
}
// Usage in middleware
export async function POST(request: Request) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
// Log failed login attempts
const { email, password } = await request.json();
const user = await authenticateUser(email, password);
if (!user) {
logSecurityEvent({
type: "auth_failure",
ip,
path: "/api/login",
details: `Failed login for ${email}`,
});
return new Response("Invalid credentials", { status: 401 });
}
return new Response("OK");
}What to Monitor
- Failed login attempts — detect brute-force attacks.
- Rate limit hits — someone is hammering your API.
- 403/401 spikes — possible unauthorized access attempts.
- Unusual outbound traffic — your server may be compromised.
- Dependency audit alerts — new CVEs in your packages.
Alerting
Set up alerts for anomalies. Services like Sentry, Datadog, or even simple webhook-based alerts can notify you when something is wrong:
// Simple alerting for critical security events
async function alertSecurityTeam(message: string) {
if (process.env.SLACK_WEBHOOK_URL) {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `[SECURITY ALERT] ${message}`,
}),
});
}
}Deployment Checklist
Before every production deployment, verify:
# 1. No secrets in source code
git log --all -p | grep -i "secret\|password\|api_key" # should find nothing
# 2. Dependencies are clean
pnpm audit
# 3. Build succeeds with production settings
pnpm build
# 4. Security headers are set (test after deploy)
curl -I https://yourdomain.com | grep -i "strict-transport\|x-frame\|x-content-type\|csp"
# 5. HTTPS works and redirects from HTTP
curl -I http://yourdomain.com # should get 301 to https
Key Takeaways
- Enforce HTTPS everywhere. Use HSTS to prevent downgrade attacks.
- Set security headers (CSP, X-Frame-Options, HSTS, etc.) on all responses.
- Keep secrets in environment variables. Validate them at startup. Never prefix secrets with
NEXT_PUBLIC_. - Audit dependencies regularly. Use Dependabot or Renovate. Commit your lock file.
- Log security events in a structured format and set up alerts for anomalies.