Backend Security · Lesson 19

CORS Configuration Best Practices

Configure CORS safely in Spring Boot and Express with exact origins, credentials, preflight caching, rejection behavior, tests, and deployment checks.

Quick answer

A secure CORS policy grants browser JavaScript the smallest cross-origin read access an application needs. Allow exact production origins, methods, and request headers; enable credentials only when cookies or HTTP authentication are truly required; give preflight responses a bounded cache lifetime; and reject unknown origins without reflecting them.

CORS is a browser response-sharing policy, not API authentication, CSRF protection, or a firewall. Non-browser clients can call the API regardless of CORS headers. Authenticate and authorize every sensitive request separately.

Threat model

Browsers enforce the same-origin policy so a page from one origin cannot freely read sensitive responses from another. CORS lets a server relax that rule for selected origins. A dangerous policy may let hostile JavaScript read account data with a victim’s cookies, while an overly strict policy can break a legitimate frontend.

The configuration should address:

  • untrusted origins attempting to read credentialed responses;
  • origin-reflection bugs that approve any caller;
  • wildcard policies accidentally reaching private endpoints;
  • unexpected methods or headers expanding the browser-accessible API;
  • cache confusion when responses vary by Origin;
  • development origins leaking into production.

CORS does not stop CSRF when a browser can send the request even though JavaScript cannot read the response. Use SameSite cookies, CSRF tokens, origin checks, and authorization as appropriate. It also does not protect an API from curl, mobile apps, backend services, or compromised allowed origins.

Understand origin matching

An origin is the tuple of scheme, host, and port:

https://app.example.test

These are different origins:

http://app.example.test
https://app.example.test
https://app.example.test:8443
https://admin.example.test

Match parsed, normalized origins against an explicit set. Do not use substring checks such as endsWith("example.test"); notexample.test and attacker-controlled subdomains can defeat naive matching. Do not approve null unless a documented client genuinely requires it and its risk is understood.

Simple requests, preflight, and actual responses

A browser may send a limited “simple” request directly and then decide whether JavaScript can read the response. Other cross-origin requests first send an OPTIONS preflight:

OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.test
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,x-request-id

An approval can look like:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.test
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Headers: Content-Type,X-Request-Id
Access-Control-Max-Age: 600
Vary: Origin

The actual response must also carry the appropriate Access-Control-Allow-Origin and, for credentialed access, Access-Control-Allow-Credentials. A successful preflight is permission to attempt the request, not proof that the user is authorized.

Choose an explicit policy

Start from application requirements:

DecisionNarrow production choice
PathsApply CORS only to browser-facing API routes.
OriginsExact HTTPS frontend origins from reviewed configuration.
MethodsOnly methods used by those routes.
Request headersContent-Type and named application headers.
Exposed headersOnly response headers JavaScript must read.
Credentialstrue only for cookie or browser HTTP-auth flows.
Max ageA bounded value such as 600 seconds.

If a public, unauthenticated resource truly may be read by any site, Access-Control-Allow-Origin: * can be appropriate. It cannot be combined with credentialed browser requests. Do not copy a public asset policy onto account APIs.

Java with Spring MVC and Spring Security

Define one centralized CorsConfigurationSource so Security processes CORS before authentication. A preflight request often has no session cookie; if security filters reject it first, legitimate browser calls fail.

@Configuration
@EnableWebSecurity
class SecurityConfiguration {
    private static final List<String> TRUSTED_ORIGINS = List.of(
        "https://app.example.test",
        "https://admin.example.test"
    );

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(TRUSTED_ORIGINS);
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        config.setAllowedHeaders(List.of(
            "Content-Type", "X-Request-Id", "X-CSRF-Token"
        ));
        config.setExposedHeaders(List.of("X-Request-Id"));
        config.setAllowCredentials(true);
        config.setMaxAge(Duration.ofMinutes(10));

        UrlBasedCorsConfigurationSource source =
            new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }

    @Bean
    SecurityFilterChain security(
        HttpSecurity http,
        CorsConfigurationSource cors
    ) throws Exception {
        return http
            .cors(customizer -> customizer.configurationSource(cors))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.OPTIONS, "/api/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .build();
    }
}

Keep production origins in validated configuration, but do not accept arbitrary environment text as a pattern. Fail startup when an origin is not an absolute HTTPS origin or contains a path, query, credentials, or wildcard.

For APIs using bearer tokens rather than cookies, credentials may be unnecessary depending on how the browser sends the token. CORS approval still does not validate that token.

Node.js with Express

The Express cors middleware supports a callback that evaluates each request origin:

import cors from "cors";
import express from "express";

const app = express();
const trustedOrigins = new Set([
  "https://app.example.test",
  "https://admin.example.test"
]);

const corsPolicy = cors({
  origin(origin, callback) {
    // Requests without Origin are not browser cross-origin reads.
    if (origin === undefined) return callback(null, false);
    if (trustedOrigins.has(origin)) return callback(null, origin);
    return callback(null, false);
  },
  credentials: true,
  methods: ["GET", "POST", "PUT", "DELETE"],
  allowedHeaders: ["Content-Type", "X-Request-Id", "X-CSRF-Token"],
  exposedHeaders: ["X-Request-Id"],
  maxAge: 600,
  optionsSuccessStatus: 204
});

app.use("/api", corsPolicy);
app.options("/api/*splat", corsPolicy);
app.use("/api", authenticate, authorize);

Returning false means the middleware omits approval headers. Some teams prefer an explicit 403 for unknown browser origins:

function rejectUnknownBrowserOrigin(req, res, next) {
  const origin = req.get("Origin");
  if (origin && !trustedOrigins.has(origin)) {
    return res.status(403).json({
      type: "https://api.example.test/problems/origin-not-allowed",
      title: "Origin not allowed",
      status: 403
    });
  }
  next();
}

Choose deliberately. Omitting headers produces a browser CORS error; a 403 provides clearer server telemetry. Never echo the received origin before checking membership.

Credentials and cookies

For credentialed cross-origin fetches, the browser request must opt in:

await fetch("https://api.example.test/api/profile", {
  credentials: "include"
});

The server must return the exact allowed origin and Access-Control-Allow-Credentials: true. Cookies must independently satisfy Secure, SameSite, domain, and path rules. Cross-site cookies commonly require SameSite=None; Secure, which increases the need for CSRF defenses.

Do not enable credentials merely because an endpoint uses Authorization: Bearer. Decide how the browser obtains and attaches the token, then minimize both token exposure and CORS permissions.

Caching and Vary: Origin

When a server returns different allow-origin values for different requests, shared caches need Vary: Origin. Framework middleware commonly adds it, but verify the built response and CDN behavior. Otherwise a response approved for one origin may be served with the wrong headers to another.

Access-Control-Max-Age caches the preflight decision in the browser. A long value reduces latency but slows emergency policy changes. Ten minutes is a reasonable starting point for many APIs; choose a duration that matches operational response requirements and browser limits.

Do not cache private response bodies publicly merely because the CORS headers are correct. HTTP caching and CORS solve different problems.

CDNs deserve an explicit production test. Send requests with two approved origins and one rejected origin, then inspect the response headers and cache keys. Confirm that a cached approval for the customer application is never reused for the administration application or an unknown site. If a CDN rule removes Vary, overrides Access-Control-Allow-Origin, or caches authenticated bodies, correct that rule rather than compensating with broader application permissions.

For multi-tenant frontends, do not load an allowlist from arbitrary request metadata. Resolve a normalized origin against an active tenant record, cache that decision briefly, and invalidate it when a tenant domain changes. A database-backed allowlist still needs the same exact scheme, host, and port comparison as a static set.

Failure handling and observability

Record:

  • normalized route group;
  • allowed or rejected decision;
  • origin hash or reviewed origin value according to privacy policy;
  • requested method and header names;
  • deployment version and policy version;
  • correlation ID.

Do not log cookies, Authorization headers, CSRF tokens, or response bodies. Rate-limit noisy rejected-origin logging so a hostile site cannot create an observability denial of service.

Return stable errors. Avoid revealing the complete allowlist or internal environment names.

Tests that matter

Test the header matrix, not only endpoint status:

  1. Allowed origin preflight returns exact origin, methods, headers, max age, and Vary: Origin.
  2. Unknown origin receives no approval header or the documented 403.
  3. An allowed actual request receives the same origin and credential header.
  4. A request from an allowed origin with a disallowed method fails.
  5. A disallowed request header is not approved.
  6. A request without Origin can still use normal authentication rules.
  7. * never appears with credentialed responses.
  8. Development origins are absent in production configuration.
  9. Authentication and authorization still reject invalid users after CORS approves the browser origin.

Spring tests can use MockMvc with OPTIONS, Origin, and Access-Control-Request-* headers. Node.js tests can use Supertest and assert both presence and absence of response headers.

Common mistakes

Reflecting every origin. Access-Control-Allow-Origin: <request Origin> is equivalent to trusting every site when no validation precedes it.

Using * with credentials. Browsers reject the combination, and the intent signals an unsafe policy.

Treating CORS as authentication. Backend and mobile callers do not rely on browser enforcement.

Allowing every method and header. This silently expands future browser access.

Forgetting preflight in Security. Authentication filters may reject OPTIONS before CORS can answer.

Substring origin checks. Parse and compare exact origins.

Mixing environments. Localhost belongs in development configuration, not the production allowlist.

Ignoring caches. Dynamic origin responses require correct variation behavior.

Practical checklist

  • Inventory browser clients, paths, methods, headers, and credential needs.
  • Use exact HTTPS production origins.
  • Reject wildcard-plus-credentials configurations.
  • Apply CORS before Spring Security authentication processing.
  • Keep authentication, authorization, CSRF, and rate limiting independent.
  • Limit allowed and exposed headers.
  • Add Vary: Origin for dynamic responses.
  • Choose a bounded preflight max age.
  • Remove localhost and preview domains from production.
  • Test allowed and rejected header matrices.
  • Inspect CDN behavior and actual production headers.
  • Monitor rejected-origin volume without logging secrets.

Frequently asked questions

Does CORS protect an API from Postman or curl?

No. CORS is enforced by browsers. Authenticate and authorize all clients at the API.

Can I allow all subdomains?

Only if every present and future subdomain has the same trust level. Exact origins are safer. If dynamic tenants require patterns, validate parsed hostnames against a controlled tenant registry.

Why does preflight fail before my controller runs?

Framework or security middleware handles OPTIONS earlier. Configure CORS at that layer and ensure it runs before authentication.

Should rejected origins receive 403?

Either a documented 403 or omission of approval headers can work. A 403 improves server-side clarity; browsers still surface cross-origin restrictions. Be consistent.

No. Use Secure and appropriate SameSite cookies, CSRF defenses, authentication, authorization, and input validation.

Should development use the production policy?

Use the same decision model but a separate reviewed origin set. Local development may allow an exact localhost port; production configuration should fail if localhost, plain HTTP, or an unapproved preview hostname appears.

Sources

Knowledge check

Check your understanding

Answer both questions correctly to mark this lesson as mastered. You can retry without penalty.

1. Which description best matches CORS Configuration Best Practices?

2. Which statement matches the lesson's quick answer?