Web Engineering ·

Web Application Security in Production: CORS, CSP, XSS, and CSRF Protection in TypeScript

A practical guide to the real attack surface of modern web applications. CORS misconfigurations, Content Security Policy, XSS prevention, CSRF patterns, and supply chain hygiene, all with working TypeScript middleware examples.

Web Application Security in Production: CORS, CSP, XSS, and CSRF Protection in TypeScript

Security bugs in production web applications almost always come from the same places: CORS configured to allow anything, XSS sinks that were missed during review, CSRF protection bolted on incorrectly, and a node_modules tree nobody has audited in months. This is not about theoretical attack vectors. It is about the specific misconfigurations that get real applications compromised.

This article walks through each of these areas with the kind of specificity that is actually useful: what the browser enforces versus what you enforce, where common implementations go wrong, and TypeScript middleware you can adapt directly.

CORS: What the Browser Actually Enforces

Cross-Origin Resource Sharing is enforced entirely by the browser. Your server does not block the request when CORS is misconfigured; the browser blocks the response from reaching JavaScript. This distinction matters because server-to-server requests, curl, and anything that is not a browser will bypass CORS entirely. CORS is not an authentication mechanism.

The browser sends a preflight OPTIONS request for any “non-simple” request (anything with a custom header, a body with Content-Type: application/json, or a method other than GET/POST/HEAD). Your server must respond with the right Access-Control-Allow-* headers before the real request proceeds.

Common misconfigurations:

  1. Reflecting the request Origin header back verbatim with no validation. This effectively allows every origin, including evil.com.
  2. Setting Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true. Browsers reject this combination, but developers often respond by switching to the reflected-origin approach, which is worse.
  3. Allowing origins with a substring check (origin.includes('myapp.com')) that also matches evil-myapp.com.

Here is a correct CORS middleware for Hono that validates against an explicit allowlist:

import { Context, MiddlewareHandler } from 'hono'

interface CorsOptions {
  allowedOrigins: string[]
  allowedMethods?: string[]
  allowedHeaders?: string[]
  allowCredentials?: boolean
  maxAge?: number
}

export function cors(options: CorsOptions): MiddlewareHandler {
  const {
    allowedOrigins,
    allowedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    allowedHeaders = ['Content-Type', 'Authorization'],
    allowCredentials = false,
    maxAge = 86400,
  } = options

  // Build a Set for O(1) lookups rather than iterating on every request
  const originSet = new Set(allowedOrigins)

  return async (c: Context, next) => {
    const origin = c.req.header('Origin')

    if (origin && originSet.has(origin)) {
      c.header('Access-Control-Allow-Origin', origin)
      c.header('Vary', 'Origin')

      if (allowCredentials) {
        c.header('Access-Control-Allow-Credentials', 'true')
      }
    }

    if (c.req.method === 'OPTIONS') {
      c.header('Access-Control-Allow-Methods', allowedMethods.join(', '))
      c.header('Access-Control-Allow-Headers', allowedHeaders.join(', '))
      c.header('Access-Control-Max-Age', String(maxAge))
      return c.text('', 204)
    }

    await next()
  }
}

The Vary: Origin header is critical. Without it, a CDN or reverse proxy can cache a response that was served for https://app.myapp.com and return it to a request from https://evil.com, which effectively strips your CORS protection at the edge.

For Express the pattern is identical, just adapted to the middleware signature:

import { Request, Response, NextFunction } from 'express'

export function corsMiddleware(options: CorsOptions) {
  const originSet = new Set(options.allowedOrigins)

  return (req: Request, res: Response, next: NextFunction) => {
    const origin = req.headers.origin

    if (origin && originSet.has(origin)) {
      res.setHeader('Access-Control-Allow-Origin', origin)
      res.setHeader('Vary', 'Origin')
    }

    if (req.method === 'OPTIONS') {
      res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
      res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
      res.setHeader('Access-Control-Max-Age', '86400')
      res.status(204).end()
      return
    }

    next()
  }
}

Content Security Policy: Directives and Nonce-Based Policies

CSP is an HTTP response header that tells the browser which sources are allowed to load scripts, styles, images, and other resources. It is the most effective browser-side defense against XSS, because even if an attacker injects a script tag, the browser will refuse to execute it if the source is not on the allowlist.

The most important directive is script-src. A common but dangerous pattern is script-src 'unsafe-inline', which allows any inline script on the page. This defeats almost all of CSP’s value.

Nonce-based policies are the production-grade approach. The server generates a cryptographically random nonce on every request, includes it in the CSP header, and adds it as an attribute to every <script> tag it renders. The browser will only execute scripts that carry the matching nonce.

import { randomBytes } from 'crypto'
import { Context, MiddlewareHandler } from 'hono'

export interface CspOptions {
  reportUri?: string
  reportOnly?: boolean
}

export function csp(options: CspOptions = {}): MiddlewareHandler {
  return async (c: Context, next) => {
    // 128 bits of entropy, base64 encoded
    const nonce = randomBytes(16).toString('base64')

    // Store on context so route handlers can add it to script tags
    c.set('cspNonce', nonce)

    const directives = [
      "default-src 'self'",
      `script-src 'self' 'nonce-${nonce}'`,
      "style-src 'self' 'unsafe-inline'",   // relax for CSS-in-JS; tighten if possible
      "img-src 'self' data: https:",
      "font-src 'self'",
      "connect-src 'self'",
      "frame-ancestors 'none'",
      "base-uri 'self'",
      "form-action 'self'",
    ]

    if (options.reportUri) {
      directives.push(`report-uri ${options.reportUri}`)
    }

    const headerName = options.reportOnly
      ? 'Content-Security-Policy-Report-Only'
      : 'Content-Security-Policy'

    c.header(headerName, directives.join('; '))

    await next()
  }
}

CSP violation reports are noisy in production, especially if you have browser extensions installed by users. Start with Content-Security-Policy-Report-Only pointing at a report collection endpoint, triage for a week or two, then move to enforcement mode. The report body is JSON and looks like this:

{
  "csp-report": {
    "document-uri": "https://app.example.com/dashboard",
    "violated-directive": "script-src-elem",
    "blocked-uri": "inline",
    "line-number": 47
  }
}

A minimal report endpoint in Hono:

app.post('/csp-report', async (c) => {
  const report = await c.req.json()
  // Ship to your log aggregator, not console.log
  logger.warn('csp_violation', { report })
  return c.text('', 204)
})

XSS Prevention: Input, Output, and DOM

XSS has three distinct vectors and each requires a different mitigation.

Reflected and stored XSS come from user-supplied content rendered into HTML. The fix is output encoding: everything that is not trusted HTML must be escaped before it is inserted into the DOM. In React this happens automatically via JSX, which calls createElement and never sets innerHTML unless you use dangerouslySetInnerHTML. In server-rendered templates the risk is higher.

DOM-based XSS happens when JavaScript on the page reads from an attacker-controlled source (URL fragment, document.referrer, postMessage) and writes to a sink (innerHTML, eval, document.write). CSP with nonces provides partial mitigation, but the real fix is auditing every path from source to sink.

For sanitizing HTML that must be rendered (rich text editor output, markdown rendered in the browser), use a purpose-built library rather than rolling your own regex:

import DOMPurify from 'dompurify'

// Only runs in browser context
function renderUserHtml(dirty: string): string {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['href', 'rel', 'target'],
    ALLOW_DATA_ATTR: false,
  })
}

For server-side sanitization (Node.js), sanitize-html provides similar controls:

import sanitizeHtml from 'sanitize-html'

function sanitizeComment(input: string): string {
  return sanitizeHtml(input, {
    allowedTags: ['b', 'i', 'em', 'strong', 'p'],
    allowedAttributes: {},
    disallowedTagsMode: 'discard',
  })
}

Input validation is not the same as sanitization. Validation rejects bad input; sanitization transforms it into something safe. Both are necessary but for different reasons. Validate at the API boundary (use Zod), sanitize before storage or rendering of content that will be treated as HTML.

CSRF: Tokens, SameSite, and Double-Submit

Cross-Site Request Forgery exploits the fact that browsers automatically include cookies with cross-origin requests. A page on evil.com can trigger a state-changing request to api.myapp.com, and if the user is logged in, their session cookie goes along for the ride.

Modern SameSite cookies have significantly reduced the CSRF surface. Setting SameSite=Lax (the browser default since 2020) prevents cross-site POST requests from including the cookie. SameSite=Strict prevents even GET requests from doing so, which is appropriate for high-sensitivity actions.

import { serialize } from 'cookie'

function setSessionCookie(res: Response, sessionId: string): void {
  const cookie = serialize('session', sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',    // or 'strict' for high-sensitivity apps
    path: '/',
    maxAge: 60 * 60 * 24 * 7,  // 1 week
  })
  res.setHeader('Set-Cookie', cookie)
}

SameSite alone is not sufficient if you need to support cross-origin requests (for example, your frontend is on app.example.com and your API is on api.example.com, which are different origins despite sharing a registrable domain). In that case, use the double-submit cookie pattern or a synchronizer token.

The double-submit cookie pattern works by setting a CSRF token as a non-httpOnly cookie and requiring the client to echo it back as a request header. Because JavaScript on a different origin cannot read your cookies, only your own frontend can perform this echo:

import { randomBytes } from 'crypto'
import { Context, MiddlewareHandler } from 'hono'
import { getCookie, setCookie } from 'hono/cookie'

const CSRF_HEADER = 'x-csrf-token'
const CSRF_COOKIE = 'csrf_token'

export function csrfProtection(): MiddlewareHandler {
  return async (c: Context, next) => {
    const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS'])

    if (safeMethods.has(c.req.method)) {
      // Issue a token on safe requests so the client has one
      if (!getCookie(c, CSRF_COOKIE)) {
        const token = randomBytes(32).toString('hex')
        setCookie(c, CSRF_COOKIE, token, {
          httpOnly: false,   // must be readable by JavaScript
          secure: true,
          sameSite: 'strict',
          path: '/',
        })
      }
      return next()
    }

    const cookieToken = getCookie(c, CSRF_COOKIE)
    const headerToken = c.req.header(CSRF_HEADER)

    if (!cookieToken || !headerToken || cookieToken !== headerToken) {
      return c.json({ error: 'Invalid CSRF token' }, 403)
    }

    await next()
  }
}

On the client side, every mutating request reads the token from the cookie and sends it as a header:

function getCsrfToken(): string {
  const match = document.cookie.match(/csrf_token=([^;]+)/)
  if (!match) throw new Error('CSRF token not found')
  return decodeURIComponent(match[1])
}

async function apiPost<T>(url: string, body: unknown): Promise<T> {
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-csrf-token': getCsrfToken(),
    },
    body: JSON.stringify(body),
    credentials: 'include',
  })
  if (!response.ok) throw new Error(`Request failed: ${response.status}`)
  return response.json()
}

Supply Chain Attacks: Auditing and Lockfile Integrity

The attack surface of a Node.js application extends to every package in node_modules. Typosquatting (lodash vs lodahs), dependency confusion, and compromised packages (the event-stream incident, ua-parser-js, node-ipc) are real vectors that have hit production systems.

Dependency auditing should be a blocking step in CI, not a suggestion:

# npm
npm audit --audit-level=high

# pnpm
pnpm audit --audit-level=high

Set --audit-level=high rather than the default moderate to reduce noise while still catching genuine vulnerabilities. Review moderate-severity findings periodically rather than blocking on them.

Lockfile integrity is your second line of defense. The lockfile pins exact versions and, for npm/pnpm, includes content hashes that verify the downloaded tarball matches what was resolved when you first installed. In CI, always use the frozen-lockfile install mode:

npm ci                          # npm: uses package-lock.json, fails if out of sync
pnpm install --frozen-lockfile  # pnpm
yarn install --frozen-lockfile  # yarn

Never commit node_modules. Always commit the lockfile. If a lockfile entry changes and nobody touched a dependency, investigate before merging.

For packages that receive arbitrary user data, consider whether you need the package at all. The security debt of a dependency compounds over time.

Tradeoffs at a Glance

ControlStrengthOperational CostWhen to Apply
CORS allowlistBlocks cross-origin browser readsLow: update list on new originsAlways
CSP with noncesBlocks injected script executionMedium: nonces need per-request generationAll apps serving HTML
CSP report-onlyNo user impact, baseline signalLowBefore enforcing CSP
SameSite=Lax cookiesStops most CSRF, zero client codeLowDefault for all session cookies
Double-submit CSRF tokenStops all CSRF including subdomainMedium: client code requiredWhen cross-origin API calls needed
DOMPurify sanitizationCleans injected HTMLLow: add to render pathAnywhere HTML is rendered from user input
npm ci in CIPrevents lockfile driftNear zeroAlways in CI
npm audit in CISurfaces known CVEsLowAlways in CI

Production Considerations

Header order matters with some reverse proxies. If you have a CDN or proxy in front of your application, check that it does not strip or override your security headers. Test with curl -I from outside your network.

CSP breaks things. When you first deploy a nonce-based CSP, expect breakage from third-party scripts, analytics, and chat widgets that inject inline code. The report-only phase is not optional; it will save you an incident.

SameSite and mobile browsers. Older iOS Safari (pre-13) treats SameSite=None as SameSite=Strict. This is worth knowing if your user base includes iOS 12 users with embedded webviews.

CORS and OPTIONS caching. The Access-Control-Max-Age value controls how long the browser caches preflight results. A value of 86400 (one day) reduces OPTIONS request volume, but it also means CORS policy changes take up to a day to propagate for users who have a cached preflight. Set it to 0 or a low value in staging environments to avoid confusion.

Timing-safe comparison for tokens. When comparing CSRF tokens or any secret values server-side, use a constant-time comparison to prevent timing attacks:

import { timingSafeEqual } from 'crypto'

function safeCompare(a: string, b: string): boolean {
  if (a.length !== b.length) return false
  const bufA = Buffer.from(a)
  const bufB = Buffer.from(b)
  return timingSafeEqual(bufA, bufB)
}

The Actual Risk Model

Most web application compromises are not clever zero-days. They are:

  • A reflected-origin CORS implementation that was “temporary” and never changed
  • An admin route that forgot to apply authentication middleware
  • An npm package compromised six months after you last looked at your dependencies
  • unsafe-inline in a CSP that was added to fix a third-party script and never removed

The mitigations in this article are not hard to implement. The challenge is doing them consistently, applying them to new routes and services as they are added, and not treating security headers as optional configuration. The right time to add security middleware is when the framework is first set up, not after the first incident.

Layered controls are the goal: CORS restricts which origins can read responses, CSP limits what scripts can execute, XSS sanitization protects HTML rendering paths, CSRF tokens verify request intent, and dependency auditing covers the supply chain. No single control is sufficient; each layer compensates for the others’ gaps.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.