CORS and CSP

CORS and CSP

CORS (Cross-Origin Resource Sharing) and CSP (Content Security Policy) are complementary browser-boundary security controls — CORS controls which origins the browser allows to make cross-origin HTTP requests to your API; CSP controls which resources the browser allows a page to load and execute — together, they enforce the browser trust boundary.


Core Idea

Browsers enforce a same-origin policy by default: a script loaded from https://app.example.com cannot make requests to https://api.other.com unless the API explicitly permits it. CORS and CSP are two distinct mechanisms that refine this boundary:

  1. CORS is a server-declared policy that instructs the browser which cross-origin requests are permitted. The server does not enforce it — the browser does, based on the server's response headers. CORS prevents unauthorized cross-origin API calls from browser scripts.

  2. CSP is a server-declared policy that instructs the browser which resources (scripts, styles, images, frames) a page is allowed to load and execute. It is the primary browser-level defense against Cross-Site Scripting (XSS) — by restricting which scripts can run, it limits the blast radius of an XSS attack even if the attacker injects script content.

The two controls are complementary: CORS governs what the browser sends (outbound API calls), CSP governs what the browser runs (inbound resource execution).


When NOT to Use

  • Do not use Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true — browsers reject this combination per the Fetch specification. Credentialed requests require a specific origin allowlist, never a wildcard.
  • Do not use CSP as the sole XSS defense. CSP is a mitigation of last resort; it reduces damage when other defenses fail. Input validation (see Input-Validation) and output encoding are primary defenses. CSP does not prevent stored XSS payloads from being saved to a database.
  • Do not use inline event handlers (onclick="...") or unattributed <script> blocks when CSP is active — they will be blocked by a script-src 'self' policy. All legitimate inline scripts must carry a per-request nonce or a precomputed hash.
  • Do not set Content-Security-Policy-Report-Only in production and assume the site is protected — report-only mode logs violations without blocking them. It is a testing/migration tool only.
  • Do not reflect the request's Origin header back as the Access-Control-Allow-Origin value without allowlist validation — reflecting the origin is equivalent to * with credentials enabled.

How It Works — CORS Preflight

CORS is enforced by the browser. The server declares which origins, methods, and headers it accepts via response headers. Two request categories exist:

Simple requests (GET, HEAD, POST with safe content types: application/x-www-form-urlencoded, multipart/form-data, text/plain) are sent directly with an Origin header. The browser checks the response for CORS headers and blocks or allows the response accordingly.

Non-simple requests (PUT, DELETE, PATCH, POST with application/json, any custom headers) trigger a preflight:

  1. Browser sends an OPTIONS request to the target URL with Origin, Access-Control-Request-Method, and Access-Control-Request-Headers.
  2. Server checks the origin against its allowlist.
  3. If allowed: server responds 204 with Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and optionally Access-Control-Max-Age (preflight cache duration in seconds).
  4. Browser validates the response. If the origin, method, and headers all match, the actual request is sent.
  5. If the origin is not in the allowlist: server returns 403 or omits CORS headers; browser blocks the response.
sequenceDiagram
    participant Browser
    participant Server

    Note over Browser,Server: Non-simple request (e.g., POST application/json with auth header)

    Browser->>Server: OPTIONS /api/resource<br/>Origin: https://app.example.com<br/>Access-Control-Request-Method: POST<br/>Access-Control-Request-Headers: Authorization, Content-Type

    alt Origin in allowlist
        Server-->>Browser: 204 No Content<br/>Access-Control-Allow-Origin: https://app.example.com<br/>Access-Control-Allow-Methods: GET, POST<br/>Access-Control-Allow-Headers: Authorization, Content-Type<br/>Access-Control-Max-Age: 600
        Browser->>Server: POST /api/resource<br/>Origin: https://app.example.com<br/>Authorization: Bearer token
        Server-->>Browser: 200 OK + response body
    else Origin not in allowlist
        Server-->>Browser: 403 Forbidden (no CORS headers)
        Note over Browser: Browser blocks response
    end

CORS Allowlist Pattern

Always maintain an explicit origin allowlist — never derive the allowed origin from the request's Origin header and reflect it back without validation. Reflecting the request origin is equivalent to * with credentials. Store the allowlist in configuration; validate case-insensitively (origins are case-insensitive per spec). Include the Vary: Origin header when the response depends on the Origin header — this prevents intermediate caches from serving a CORS response for one origin to a different origin.


How It Works — Content Security Policy

CSP is delivered as an HTTP response header (Content-Security-Policy) or a <meta> tag. It declares a policy as a set of directives. Each directive specifies which source expressions are allowed for a resource category.

Core directives:

DirectiveControlsNotes
default-src 'self'Fallback for all resource typesRestricts to same origin unless overridden
script-srcJavaScript sourcesMost critical for XSS mitigation
style-srcCSS sourcesInline styles require nonce or hash
connect-srcfetch / XHR / WebSocket targetsRestrict to known API origins
frame-ancestors 'none'Who can embed this page in a frameReplaces X-Frame-Options; prevents clickjacking
upgrade-insecure-requestsHTTP → HTTPS upgradeInstructs browser to upgrade all HTTP subresource requests
object-src 'none'<object>, <embed>, <applet>Should always be 'none' — no legitimate use cases remain
base-uri 'none'<base> element targetsPrevents base-tag injection attacks

Nonce-Based CSP

A nonce is a cryptographically random value generated per request. The server includes it in the CSP header (script-src 'nonce-{randomValue}') and in the nonce attribute of each legitimate <script> tag. The browser executes only scripts whose nonce attribute value matches the CSP header value. Inline scripts injected by an attacker do not have the nonce and are blocked.

The nonce must be regenerated on every response — a static nonce provides no security benefit because an attacker who observes the nonce in one response can reuse it in a subsequent injection.

Content-Security-Policy: script-src 'nonce-r4nd0mV4lu3' 'strict-dynamic'; object-src 'none'; base-uri 'none'

'strict-dynamic' extends nonce trust to scripts dynamically loaded by a trusted script — enabling SPAs and dynamically imported modules without needing to list every CDN or chunk URL.

Hash-Based CSP

Instead of a nonce, the server computes the SHA-256 hash of the exact script content and includes it in the CSP header (script-src 'sha256-{base64Hash}'). The browser executes the script only if its computed hash matches the declared hash.

Hash-based CSP is appropriate for static scripts that do not change per request — for example, inline configuration blocks rendered at build time. Nonce-based CSP is appropriate for dynamic pages where script content is server-rendered per request. The two modes cannot be combined for the same script — choose one per deployment context.

CSP ModeWhen to UseRegenerated Each Response?
Nonce-basedDynamic pages with server-rendered scriptsYes — must be random per request
Hash-basedStatic inline scripts (build-time generated)No — hash is stable across requests

TypeScript Example

Node.js/Express middleware that sets CORS headers from an explicit allowlist and generates a per-request nonce for CSP:

import { randomBytes } from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
 
const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://admin.example.com',
]);
 
export function corsAndCspMiddleware(req: Request, res: Response, next: NextFunction): void {
  const origin = req.headers.origin ?? '';
 
  // CORS: explicit allowlist — never reflect origin without validation
  if (ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');  // required: response varies by origin
  }
 
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type');
    res.setHeader('Access-Control-Max-Age', '600');
    res.status(204).end();
    return;
  }
 
  // CSP: per-request nonce — must be regenerated every response
  const nonce = randomBytes(16).toString('base64');
  res.locals['cspNonce'] = nonce;  // available in templates: <script nonce="<%= cspNonce %>">
  res.setHeader(
    'Content-Security-Policy',
    `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`
  );
 
  next();
}

Java Example

Spring Security 6.x configuration with CORS allowlist and CSP header:

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.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
 
@Configuration
public class SecurityConfig {
 
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .headers(headers -> headers
                // CSP — nonce injection requires server-side templating; static policy shown here
                .contentSecurityPolicy(csp ->
                    csp.policyDirectives("script-src 'self'; object-src 'none'; frame-ancestors 'none'")
                )
            );
        return http.build();
    }
 
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        // Explicit allowlist — never setAllowedOriginPatterns("*") with credentials
        config.setAllowedOrigins(List.of("https://app.example.com", "https://admin.example.com"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setAllowCredentials(true);
        config.setMaxAge(600L);
 
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

  • Input-Validation — CSP is a mitigation layer; input validation is the primary XSS defense
  • Session-ManagementSameSite cookie attribute is the session-layer complement to CORS; both protect against cross-origin attacks
  • Zero-Trust-Architecture — browser boundary is the edge enforcement point for external subjects; CORS/CSP enforce the browser PEP
  • API-Key-Authentication — API keys transmitted in headers must be declared in Access-Control-Allow-Headers in the CORS policy

(Leave as placeholder — backlinks added in Phase 40 sweep)