Skip to main content

Zero-Trust Web Security: Basic Hygiene Rules for Modern Web Devs

June 2, 2026

In modern web development, the traditional perimeter security model is obsolete. Assuming that resources are safe because they sit behind a corporate firewall, a private VPC, or a valid JWT in localStorage leaves systems exposed to lateral movement, configuration mistakes, and supply chain attacks. Zero-Trust Web Security operates under a single, relentless rule: never trust implicitly, always verify explicitly.

This is not a principle for security engineers alone. It is a baseline for every developer who writes a cookie, an API handler, or a CI/CD workflow. This post explains the core Zero-Trust tenets as applied to web applications, provides code examples for cookie hardening, Content Security Policy, server-side JWT validation, and CI dependency scanning.


The Three Zero-Trust Tenets

Zero-Trust is not a product or a configuration file. It is a design mindset built on three principles:

  1. Verify Explicitly: Authenticate and authorize every request independently. Never assume that because a user was valid ten minutes ago they are valid now. Session tokens can be stolen, revoked, or expired.
  2. Use Least Privilege: Limit every service account, API token, and user session to the minimum permissions required to complete the specific task. A blog reader should not have write access to the database.
  3. Assume Breach: Design your system on the assumption that attackers have already compromised at least one layer. Minimize blast radius by isolating services, encrypting data at rest and in transit, and never storing secrets in client-accessible storage.

1. Hardening Cookies Against Theft

Session hijacking remains one of the most common attack vectors. If your application stores session tokens in cookies accessible to client-side JavaScript, a single XSS vulnerability allows an attacker to steal every active session.

Three flags are mandatory on any authentication cookie:

  • HttpOnly: Prevents document.cookie from reading the cookie value. JavaScript cannot access it regardless of XSS.
  • Secure: Forces the browser to transmit the cookie exclusively over HTTPS. Prevents interception on unencrypted connections.
  • SameSite=Strict or Lax: Restricts cross-site cookie transmission, blocking CSRF (Cross-Site Request Forgery) attacks.
// app/api/auth/session/route.ts
import { cookies } from 'next/headers';

export async function createSessionCookie(token: string) {
  const cookieStore = await cookies();
  cookieStore.set('session_id', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    path: '/',
    maxAge: 60 * 60 * 24, // 24 hours
  });
}

Never store session tokens in localStorage or sessionStorage. These are JavaScript-accessible and offer no XSS protection.


2. Content Security Policy (CSP) Headers

A Content Security Policy is an HTTP response header that restricts which domains the browser is permitted to load scripts, styles, fonts, and images from. A strict CSP eliminates the most common XSS execution vectors even if an attacker manages to inject a <script> tag into your page.

Configure CSP headers in next.config.ts alongside other essential security headers:

// next.config.ts
const cspHeader = `
  default-src 'self';
  script-src 'self' 'unsafe-inline' https://va.vercel-scripts.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' blob: data: https:;
  font-src 'self' https://fonts.gstatic.com;
  connect-src 'self' https://api.your-domain.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  upgrade-insecure-requests;
`;

const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: cspHeader.replace(/\s{2,}/g, ' ').trim(),
          },
          { key: 'X-Frame-Options', value: 'DENY' },
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=31536000; includeSubDomains; preload',
          },
          {
            key: 'Permissions-Policy',
            value: 'camera=(), microphone=(), geolocation=()',
          },
        ],
      },
    ];
  },
};

export default nextConfig;

Note the Permissions-Policy header—it restricts access to sensitive browser APIs (camera, microphone, geolocation) unless your application explicitly requires them.


3. Server-Side JWT Validation

Storing JWTs in HTTP-only cookies (rather than localStorage) is half the security equation. The other half is verifying the token on every sensitive request, not just on the session creation step.

A common mistake is decoding the JWT payload on the client and trusting the claims without re-validating the signature server-side:

// ❌ Dangerous: trusting client-decoded token claims
const token = request.cookies.get('session_id')?.value;
const payload = JSON.parse(atob(token.split('.')[1])); // Just base64 decoding — no verification
if (payload.role === 'ADMIN') { /* ... */ }

Always verify the JWT signature using a trusted library with your server-side secret:

// ✅ Correct: verify signature and expiry on the server
import { jwtVerify } from 'jose';

export async function getVerifiedSession(request: Request) {
  const token = request.cookies.get('session_id')?.value;
  if (!token) return null;

  try {
    const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
    const { payload } = await jwtVerify(token, secret, {
      algorithms: ['HS256'],
    });

    // Check expiry and required claims
    if (!payload.sub || !payload.role) return null;
    
    return payload as { sub: string; role: string; exp: number };
  } catch {
    // Token invalid, tampered, or expired
    return null;
  }
}

Call getVerifiedSession() at the start of every protected API route or Server Action. Never skip it on the assumption that the cookie was "already verified" at login.


4. Automated Dependency Vulnerability Scanning in CI

The most invisible attack surface in modern web applications is the node_modules directory. Third-party packages are a primary vector for supply chain attacks—malicious packages have been injected into popular npm packages through compromised maintainer accounts.

Automate vulnerability scanning on every pull request with GitHub Actions:

# .github/workflows/security-audit.yml
name: Security Audit

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Install Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Install pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # Fails the build on high or critical severity vulnerabilities
      - name: Run Security Audit
        run: pnpm audit --audit-level=high

      # Check for known malicious packages via Socket.dev
      - name: Socket Security Scan
        uses: SocketDev/socket-security-action@v1
        with:
          api-key: ${{ secrets.SOCKET_API_KEY }}

The Socket.dev action goes beyond npm audit — it detects install scripts, obfuscated code, and behavioural anomalies in packages that are not yet listed in the CVE databases.


Conclusion

Zero-Trust web security is not a product you purchase. It is a set of engineering disciplines you apply consistently: scope cookies with the full set of security flags, enforce a strict Content Security Policy, verify JWT signatures on every sensitive request, and block known vulnerable dependencies from merging. None of these steps require specialized security expertise—they are hygiene. Applied together, they eliminate the most common attack vectors before they become incidents.

Recommended Posts