A browser runs code from unrelated parties inside one application, often in one process tree, while that code can reach private networks and authenticated services. The web remains usable because the browser assigns content to security principals and mediates interactions between them. The central principal is the origin; the central default is that one origin cannot freely inspect another origin’s data.
That default is powerful but deliberately incomplete. Cross-origin images, links, forms, and requests are fundamental web features. CORS grants selected scripts permission to read cross-origin responses. Content Security Policy constrains what a document may load or execute. Cookie attributes affect when ambient credentials accompany requests. Framing and isolation headers define still other boundaries. Secure design begins by treating these as independent controls rather than expecting one header to mean “only my frontend can use this API.”
Identify the Browser’s Security Principals
An origin is the tuple of scheme, host, and port after URL normalization. These pages do not all share an origin:
| URL | Relationship to https://app.example |
|---|---|
https://app.example/settings |
Same origin; path is irrelevant |
http://app.example/ |
Cross-origin; scheme differs |
https://api.example/ |
Cross-origin; host differs |
https://app.example:8443/ |
Cross-origin; port differs |
Origin is not the same as site. Modern cookie rules commonly use a scheme plus the registrable domain to decide whether a context is same-site. https://app.example and https://api.example can therefore be cross-origin but same-site. This distinction explains why the same-origin policy may block JavaScript response access while a SameSite cookie can still accompany the request.
Documents can also receive opaque origins that compare unequal even when their source URL looks related. Sandboxed frames without an appropriate origin permission, certain generated URLs, and data documents may behave this way. Code should not infer trust from a visible host alone.
Subdomains are separate origins for a reason. If an untrusted tenant controls tenant.example, it should not automatically read admin.example. Avoid broad domain cookies and legacy attempts to relax origin checks. Place content with different trust levels on hosts and credential scopes that reflect those levels; an origin boundary is meaningful only if every deployment at that origin is equally trusted.
The browser is one enforcement point, not the authority for business permissions. Servers must authenticate callers and authorize every operation. A non-browser client can omit Origin, forge ordinary fields, and ignore CSP completely.
Understand What the Same-Origin Policy Blocks
The same-origin policy is a family of restrictions, not one Boolean rule. In broad terms, a script can freely interact with same-origin documents and responses. Cross-origin interactions are often sendable or embeddable but not readable.
For example, a page can navigate to another origin, submit an HTML form to it, display many cross-origin images, and include a cross-origin stylesheet under applicable rules. Those capabilities made linking and composition possible before JavaScript APIs existed. The initiating page usually cannot read the resulting document, inspect protected response bytes, or traverse a cross-origin frame’s DOM.
This send/read distinction is a foundational threat-model fact. If bank.example changes state through a cookie-authenticated form POST, an attacker page may be able to cause that POST even though it cannot read the response. That is a cross-site request forgery problem, not a CORS read problem. The bank needs request integrity controls such as SameSite cookies, anti-CSRF tokens, and origin verification appropriate to its clients.
Some information can leak without direct response access. Load success, dimensions, error behavior, timing, cache effects, and resource usage may reveal facts. Browsers add resource-specific restrictions and privacy defenses, but applications should avoid putting secrets in URLs or exposing sensitive state through distinguishable cross-origin resources.
Cross-window communication is explicit. A page may use postMessage across origins, but the receiver must compare event.origin with an exact allowlist and validate event.data as untrusted input. Sending with a wildcard target origin can disclose data if the destination navigates before delivery. Holding a window reference establishes a communication route, not trust.
Treat CORS as a Delegated Read Capability
Cross-Origin Resource Sharing lets a response origin tell the browser which requesting origins may expose that response to script. A request from https://app.example to https://api.example/profile can include an Origin field. The API may answer:
Access-Control-Allow-Origin: https://app.example
Vary: Origin
If the policy and request mode agree, the browser makes the response available to the calling script. Without permission, the network exchange may still occur, but script receives a CORS failure rather than readable response details.
The allowed origin is an origin, not an arbitrary URL prefix. Parse configured values as origins and compare exact normalized values. Tests such as origin.endsWith("example"), regular expressions without careful anchoring, or reflecting any supplied origin turn the browser’s check into an attacker-controlled echo. https://app.example.attacker.test is not a subdomain of example, and trusted suffix matching must still account for dots, schemes, and ports.
When responses vary by requesting origin, Vary: Origin tells shared caches that the field affects the representation metadata. A cache that reuses a response carrying one tenant’s allow-origin value for another origin can cause availability failures or, with a broken policy, exposure. Prefer a small configured allowlist over deriving permission from request headers or database text with unclear ownership.
CORS does not authenticate a user, authorize a resource, prevent direct HTTP clients from calling an endpoint, or make response content public to all contexts. It is a browser-enforced delegation from the response origin to a requesting script origin. Public read-only resources may use Access-Control-Allow-Origin: *; user-specific credentialed resources require a narrower design.
Follow Preflight and Request Classification
Browsers can issue some cross-origin requests directly. Methods and author-controlled fields limited to a safelisted set, with restricted content types, generally avoid a preflight. This preserves compatibility with servers that have always accepted forms. A JSON POST, a PUT, or a request with an Authorization or custom header usually requires an OPTIONS preflight first.
A representative exchange is:
OPTIONS /profile HTTP/1.1
Origin: https://app.example
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: content-type, if-match
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Methods: PATCH
Access-Control-Allow-Headers: Content-Type, If-Match
Access-Control-Max-Age: 600
Vary: Origin
The browser sends the actual request only if the preflight authorizes its origin, method, and requested headers. The server should answer preflight through the same route ownership and policy source as the endpoint. A generic proxy rule that allows every method while the application assumes a narrow policy creates drift.
Preflight caching reduces repeated checks, but policy changes may not take effect in every client immediately. Use a bounded maximum age during rollout and revoke access at the server authorization layer when urgency matters. Include preflight paths in monitoring: failures are often mislabeled as application outages because JavaScript exposes limited error details.
Preflight is not a general proof that a request is safe. Directly sent requests can still mutate state, and non-browser clients can send any method without asking first. An API may intentionally require a non-safelisted content type and anti-CSRF header as part of its browser request-integrity design, but the server must reject alternate simple encodings and maintain a strict CORS allowlist for that reasoning to hold.
Handle Credentials and CSRF Separately
Fetch defaults and cookie attributes determine whether credentials accompany a request. For a cross-origin credentialed fetch, script commonly opts in with credentials: 'include', and the response must grant the exact origin plus credential access:
const response = await fetch('https://api.example/profile', {
method: 'GET',
credentials: 'include',
});
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Credentials: true
Vary: Origin
The wildcard allow-origin value cannot be used to expose a credentialed response. This constraint prevents a server from combining ambient user credentials with readable access for every origin. It does not replace endpoint authorization.
Cookies need deliberate Secure, HttpOnly, SameSite, domain, and path settings. HttpOnly prevents script from reading a cookie but does not stop the browser from attaching it to requests. SameSite=Lax or Strict can reduce cross-site request forgery, subject to the chosen navigation and product behavior. SameSite=None enables cross-site contexts and requires Secure, increasing the need for anti-CSRF design. A host-only cookie avoids sharing credentials with sibling subdomains.
Token-based APIs have different tradeoffs. An Authorization header is not ambient in the way a cookie is, and it triggers preflight cross-origin, but malicious script running in the trusted origin can still use any token available to that script. Storing a bearer token in browser-accessible storage increases the impact of cross-site scripting. CORS cannot help when hostile code executes at an allowed origin.
For state changes, validate authentication, resource authorization, expected content type, and request integrity. Check an anti-CSRF token or exact Origin/Referer policy where appropriate, and make sensitive actions resistant to replay. Decide behavior for absent origin fields because privacy tools and non-browser clients exist; do not silently classify absence as trusted.
Use CSP to Constrain Code and Resource Loading
Content Security Policy is a response policy for the protected document. It constrains where scripts, styles, images, connections, frames, and other resources may come from. Its most important security role is reducing the exploitability of injected markup by refusing unauthorized script execution.
A nonce-based starting policy might be:
Content-Security-Policy: default-src 'none'; script-src 'nonce-r4nd0m' 'strict-dynamic'; style-src 'self'; img-src 'self' https://images.example; connect-src 'self' https://api.example; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'
Generate an unpredictable nonce for each response and place it only on scripts that the server intends to execute. Do not use a fixed nonce, expose it through untrusted templates, or add 'unsafe-inline' to make failures disappear. Hash sources fit immutable inline scripts. Host allowlists alone are weaker when a permitted host serves user-controlled files, JSONP-like endpoints, or a compromised dependency.
Directives are capability-specific. connect-src limits destinations used by fetch-like APIs; it does not configure those destinations’ CORS policies. frame-ancestors controls who may embed the protected page; it is not the same as frame-src, which controls what the page may embed. form-action limits submission targets, while base-uri 'none' prevents an injected base element from rewriting relative URL resolution.
Deploy with Content-Security-Policy-Report-Only first to discover dependencies, but treat reports as noisy, attacker-influenced telemetry that may contain sensitive URLs. A report-only policy does not block anything. Move to enforcement with narrow canaries, and keep automated browser tests so future build tooling does not require broad exceptions.
CSP is defense in depth, not an input sanitizer. It cannot prevent all markup injection, logic flaws in trusted scripts, or data exfiltration through every permitted destination. Trusted Types, where supported and adopted, can further restrict assignment to dangerous DOM injection sinks, but it likewise requires deliberate application integration.
Control Embedding and Cross-Origin Isolation
Clickjacking places a sensitive page in a transparent or disguised frame and convinces a user to interact with it. Use CSP frame-ancestors to declare which ancestors may embed a page. frame-ancestors 'none' forbids framing; 'self' permits same-origin ancestors; exact partner origins support intentional embedding. The older X-Frame-Options field remains useful for compatibility in simple deny or same-origin cases, but CSP provides the expressive policy.
Sandboxed frames reduce capabilities of embedded content. An <iframe sandbox> starts restrictive, then tokens selectively restore scripts, forms, origin identity, popups, or navigation abilities. Combining permissions carelessly can let same-origin content escape intended restrictions. Put truly untrusted active content on a separate origin without valuable cookies rather than relying on one attribute to compensate for shared trust.
Cross-Origin-Opener-Policy (COOP) controls relationships between top-level browsing contexts. Cross-Origin-Embedder-Policy (COEP) requires embedded cross-origin resources to opt into compatible sharing through CORS or Cross-Origin-Resource-Policy (CORP). Together, suitable policies can create a cross-origin-isolated context needed for powerful features such as shared memory while reducing cross-origin process relationships.
These headers have compatibility costs. COOP can sever opener relationships used by authentication popups. COEP can block third-party images, scripts, workers, or frames that do not send suitable fields. CORP is a resource’s assertion about eligible embedding contexts, not a substitute for CORS when script needs to read response data. Inventory every embedded resource and popup flow before enforcement.
Work Through a Split-Origin Application
Consider an account application at https://app.example calling https://api.example. Authentication uses a host-only, Secure, HttpOnly, SameSite=Lax session cookie on the API. The SPA reads profile data and changes a display name. Marketing content at https://www.example and tenant pages under unrelated hosts must have no API read access.
The API keeps one exact CORS allowlist entry: https://app.example. It returns that origin and Vary: Origin for approved requests, enables credentials, allows GET and PATCH, and permits only Content-Type, If-Match, and an anti-CSRF header. The update endpoint accepts JSON only, verifies the session and account authorization, validates an anti-CSRF token bound to the session, and uses If-Match to prevent blind overwrites. CORS is responsible only for whether the SPA may read the response.
The app document enforces a nonce-based CSP. connect-src permits the API, frame-ancestors 'none' prevents embedding, form-action 'self' prevents injected forms from posting elsewhere, and object-src 'none' removes an unnecessary plugin surface. User avatars are served from a media origin allowed only by img-src; that permission does not let scripts fetch and inspect arbitrary media responses.
If an attacker page submits a plain form to the API, the endpoint rejects its content type or anti-CSRF proof even though the browser may send the request. If attacker script tries a credentialed JSON fetch, preflight receives no CORS grant and the actual request is not sent. If a non-browser client bypasses both browser checks, server authentication, authorization, CSRF/request rules, and validation still protect the operation.
This layered result is the goal: each control has one explainable responsibility, so weakening image policy does not silently weaken API authorization and adding a new frontend origin requires an explicit review of credentials and trust.
Test Policies as Adversarial Contracts
Automate a matrix of origins, methods, headers, credentials, and endpoints. Test the allowed origin, an unlisted sibling subdomain, a lookalike suffix, null origin where relevant, HTTP instead of HTTPS, and a different port. For preflights, vary one requested method or field at a time. Confirm denied actual requests are not processed when preflight is required, and confirm simple cross-site requests cannot change state even though they may be sent.
Use real browser tests because command-line HTTP clients report headers but do not enforce the browser model. From controlled attacker and trusted origins, attempt fetch reads, form submissions, iframe embedding, popup messaging, and DOM access. Verify cookie attachment separately from response readability. Inspect browser console violations, network requests, and server-side mutation records.
For CSP, test that nonce-bearing application bundles execute, injected inline script does not, unexpected connections fail, forms cannot leave approved targets, and the page cannot be framed. Keep a report-only policy stricter than enforcement to preview the next change. Validate COOP/COEP flows across login popups, workers, CDN resources, and third-party integrations before relying on cross-origin isolation.
In production, record policy decisions using normalized origin and stable reason codes, but avoid logging credentials, full URLs with secrets, or request bodies. Alert on changes in preflight denial rates, CSP violations by directive, blocked embedding, and cross-origin isolation failures after deployments. Rate-limit report endpoints because clients and attackers can generate large volumes.
The durable model is a set of boundaries. Origins separate principals; the same-origin policy restricts observation and interaction; CORS delegates selected response readability; cookie and CSRF controls protect ambiently authenticated requests; CSP constrains a document’s executable and loading capabilities; framing and isolation headers govern relationships among contexts. Security comes from assigning each boundary a precise job, enforcing business authority on the server, and testing from both trusted and hostile browser contexts instead of treating a collection of headers as a single shield.