Skip to main content

Secure Deployment

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-run

Security 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

HeaderPurpose
Strict-Transport-SecurityForces HTTPS, prevents SSL stripping attacks
X-Frame-OptionsPrevents clickjacking by blocking iframes
X-Content-Type-OptionsStops browsers from MIME-sniffing responses
Referrer-PolicyControls how much URL info leaks to other sites
Permissions-PolicyDisables browser APIs you do not need
Content-Security-PolicyControls 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.*.local

In 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 outdated

Automated 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: 10

Lock 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-lockfile

Checking 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-vulnerable

Monitoring 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.