Yes, another website can make your logged-in user’s browser send a request to your API. Whether it changes anything depends on 3 settings: the session cookie’s SameSite attribute, a token or origin check on every state-changing request, and a CORS policy that names its origins exactly. CSRF mitigation is the first two; the third decides who reads the answers.

CSRF mitigation and CORS: what a CSRF attack is and what the two controls do

CSRF mitigation stops another website from making a logged-in user’s browser change something in your app. A CSRF attack is that forged request, carried by the user’s own session cookie. Mitigation rests on 3 layers: a SameSite session cookie, a token or origin check on every state-changing request, and no state changes on GET.

Both controls are one corner of web app security, and both rest on the browser’s same-origin policy, which has two halves. A page on any site can send a request that looks like a form submission to your API, and your server receives it; what the browser withholds is the response, which that page’s script can read only if your server opts in.

CSRF (cross-site request forgery) abuses the first half. The simplest CSRF explanation is a hidden form: a page on another site holds a form that posts a change-email request to your app, and script on that page submits it the moment it loads. When a logged-in user opens the page, the request may carry their real session cookie, so your server sees a valid logged-in request the user never meant to make. MDN’s guide to CSRF has the same attack explained with a bank transfer, and says it is possible when a site uses HTTP requests to change some state, checks the user with cookies alone, and uses only request parameters an attacker can predict.

CORS (cross-origin resource sharing) relaxes the second half. It is a set of response headers that tell the browser which other origins may read your responses, and whether credentials may come along; MDN’s CORS guide calls it “an HTTP-header based mechanism” for exactly that. CORS security, then, is about who may read your responses, never about who may send you requests.

CSRF protection means making sure a cross-site request cannot change state: a cookie the browser does not send cross-site, a secret the other site cannot know, or a check of where the request came from. The CSRF security question for any route is whether a request from another site can change state there.

QuestionSame-origin policyCORSCSRF defenses
Who enforces itThe browserThe browser, reading your response headersYour server, plus the browser for SameSite
What it stopsAnother origin’s script reading your responsesNothing; it lets the origins you name read responsesA cross-site request changing state
What it does not stopThe request reaching your serverClients that are not browsers, such as curl or another serverScript already running on your own origin (XSS)
Where it is setBuilt into the browserResponse headers from your API or your edgeCookie attributes, tokens and origin checks in your app

Which defense you need follows from how your app sends the login, in my reading of how the browser attaches credentials. A cookie is attached by the browser on its own; an Authorization header is attached only by your own JavaScript.

How your app sends the loginDoes classic CSRF apply?What to do
Session in a cookie (Django, Rails, Supabase’s server-side setup, Auth.js/NextAuth)YesSameSite, plus a token or origin check on every state-changing route
Token in an Authorization header that your JavaScript adds (the Supabase client, a single-page app with a JWT)No: there is no ambient credential to rideGuard against XSS and keep CORS exact
Both at onceYesTreat it as cookie auth

CORS never acts as access control on either row. The Express cors docs put it plainly: “Any HTTP client (curl, Postman, another server) can call your API regardless of CORS settings.”

One dependency sits underneath all of this. OWASP’s cheat sheet warns that cross-site scripting “can defeat all CSRF mitigation techniques”, because script running in your origin can read the token. The defenses below assume XSS is handled; how to mitigate cross-site scripting is its own job, with the full comparison of the two bugs.

What goes wrong without it

Each row below starts as a fix that works on the day. Rows one, three and four leave a CORS vulnerability behind, row two is the error that leads to row one, and the last two leave a CSRF one. The last column is my reading of each rule’s effect, not a quote.

What the code doesWhy the builder wrote itWhat another site can now do
Copies whatever Origin arrives into Access-Control-Allow-Origin and sends Access-Control-Allow-Credentials: trueThe wildcard below failed with credentials, and this makes the error go awayRead authenticated responses as the logged-in user
Sends Access-Control-Allow-Origin: * with credentialsCopied from a starter exampleNothing yet: the browser blocks the response and reports a CORS error
Matches origins with endsWith("yourapp.com") or a regular expression with no anchorsTo let subdomains or previews throughPass the check from a look-alike domain someone else registers
Puts null on the allow-listTo make a local file or sandboxed test page workPass the check from any page that arranges a null origin
Changes state on GET (a delete link, an unsubscribe that acts on load)A link was easier than a formTrigger it with a link; SameSite=Lax still sends the cookie on a top-level GET
Sets SameSite=None on the session cookie with no token behind itAn embedded widget or a front end on another domain stopped workingSend forged state-changing requests with the cookie attached

The first row is the CORS misconfiguration that matters most: any site the user visits can read your API’s responses as that user. A credentialed CORS request with a wildcard never gets that far, because MDN says the browser will “block access to the response, and report a CORS error in the devtools console”, and the first row is the fix that makes that error disappear. So the security risk of letting CORS allow all origins turns on credentials. My reading of MDN’s rules: a wildcard with no credentials is fine for a truly public API with no login. It becomes a risk on a route that trusts its place inside a private network, which any page the user opens can then read, and on a cookie route, where the browser’s CORS error invites the first row as the fix.

The third row is a cross-origin resource sharing vulnerability that looks like care. The Express cors README’s own example pattern /example\.com$/ “will reflect any request that is coming from an origin ending with “example.com"", and OWASP’s cheat sheet asks that example.org.attacker.com never pass an origin check. A cross-origin resource sharing attack on rows one, three or four needs only a page on an origin the check lets through, calling your API with credentials included; the CORS attack is ordinary JavaScript on a site the user happens to open.

The last two rows are the cross-site request forgery vulnerability in its two common shapes, and both turn on SameSite. MDN’s Set-Cookie reference says a Lax cookie still goes out on a cross-site top-level navigation that uses a safe method, which excludes POST, PUT and DELETE, and that None sends the cookie with both cross-site and same-site requests. The CSRF examples OWASP gives are what a forged request can do: “transfer funds, change a password, make an unauthorized purchase, elevate privileges for a target account”. A CSRF vulnerability only reaches as far as the user’s own permissions, which on an admin account is everything.

In one app I audited, the API allowed requests from any website and allowed credentials, with every method and header, whenever its environment setting said development (the default when unset). It is the kind of line any application security checklist should carry. My reading: a CORS setting whose damage stays limited only while the token stays out of a cookie is one refactor away from a cross-site data leak.

In my audits, 10 of the 21 third-party apps trusted the client: the server accepted whatever the browser asserted. The 21 are the third-party apps from my June and July 2026 audits; they were selected, not sampled at random, so the count says nothing about AI-built apps as a whole. A CSRF gap has a similar root, in my reading: the server treats a request as the user’s intent because the browser sent it.

Look for CSRF in the current OWASP Top 10 and you find it inside A01:2025 Broken Access Control, where CWE-352 is one of the notable CWEs. CORS sits in the same category of the OWASP Top 10: the OWASP Top 10 lists “CORS misconfiguration allows API access from unauthorized or untrusted origins” among common access control vulnerabilities. The last edition to list CSRF on its own was 2013, as A8; the 2017 release notes give the reason in one line: “as many frameworks include CSRF defenses, it was found in only 5% of applications”. The attack is still in the list, just filed inside a wider category.

How to do it on the common stacks

Which part applies follows from the auth-style table above: a cookie-session app needs the next part, a header-token app reads the one after, and every app whose front end lives on a second origin needs the CORS part.

Protect state-changing calls: SameSite, tokens and Fetch Metadata

A cookie-session app protects state-changing calls with 5 layers: no state change on GET, a SameSite, Secure, HttpOnly session cookie, the framework’s built-in CSRF token, a server-side check of Origin or Sec-Fetch-Site, and a password or second factor re-prompt on the few actions that matter most.

The order is my working rule: each layer is cheap, and each one covers a gap the one before it leaves.

  1. 01 No state change on GET, ever: deletes, unsubscribes and updates take POST, PUT, PATCH or DELETE
  2. 02 Session cookie set with SameSite=Lax or Strict, plus Secure and HttpOnly, written out explicitly
  3. 03 The framework's built-in CSRF token on every state-changing request
  4. 04 A server-side check that rejects a state-changing request whose Origin is not yours, or whose Sec-Fetch-Site is cross-site
  5. 05 A password or second-factor re-prompt before changing the email, the password, a payout account, or deleting the account

Set SameSite explicitly, because the default differs by browser. MDN’s compatibility data shows Chrome (from 80) and Edge (from 86) defaulting to Lax, Firefox doing so only behind a preference, and Safari not at all. Lax keeps the GET gap from the failure table, and MDN notes that when Lax is applied as a default it also lets POST requests through if the cookie was set no more than two minutes earlier. OWASP’s cheat sheet sums up the attribute as useful defense in depth that “does not replace a proper CSRF defense in most deployments”.

For tokens, OWASP’s CSRF prevention cheat sheet starts with “check if your framework has built-in CSRF protection and use it”. Django’s CSRF protection comes from middleware that “is activated by default”, and the Rails security guide says new Rails apps verify a security token because default_protect_from_forgery is true by default. Next.js on Server Actions security says Server Actions accept only POST and compare the Origin header with Host, aborting the request if they differ; the same check for Route Handlers is not stated in that guide, so a Route Handler that trusts the session cookie needs its own.

Express is the odd one out. Its security best-practices page has no CSRF section, and the old csurf middleware’s repository was archived in May 2025 and says it is “no longer actively maintained”. On Express, use a maintained middleware that implements what OWASP calls the Signed Double-Submit Cookie, which the cheat sheet marks as recommended, and avoid the naive variant it marks as discouraged.

A CSRF token works because the server puts an unpredictable value in its own page and checks it on every state-changing request. It prevents the attack because the other site cannot read your page, and OWASP’s reasoning is short: “without a CSRF token, an attacker cannot create valid requests to the backend server.”

The origin check is the fourth layer. Fetch Metadata headers start with Sec-, so script cannot change them, and Sec-Fetch-Site tells the server whether a request is same-origin, same-site, cross-site or none. OWASP’s default policy rejects POST, PUT, PATCH and DELETE when Sec-Fetch-Site is cross-site, and treats a fallback to the Origin check as mandatory for browsers that do not send the header; MDN marks the header as available across browsers since March 2023. The web.dev guide to Fetch Metadata request headers calls the pattern a Resource Isolation Policy.

An API that accepts only Content-Type: application/json gets one more layer for free. MDN says that header keeps a fetch() request from being a simple request, so a cross-site form cannot send it without a preflight your CORS policy has to approve. It is not a complete defense on its own: MDN adds that a response listing the sender’s origin in Access-Control-Allow-Origin and sending Access-Control-Allow-Credentials reopens the hole, and OWASP warns that a JSON API may also accept text/plain, which would be vulnerable to CSRF.

A token does nothing against a bot that loads your form first and submits it with the token in place; that is a separate fight: bots submitting our signup form. Webhook routes are a routine exemption, since they carry a provider signature instead of a user session: they skip the token check and must verify that signature instead, which belongs to webhooks security.

Token in a header: when CSRF does not apply, and what replaces it

An app that sends its token in the Authorization header is not exposed to classic CSRF, because the browser never attaches that header by itself. The risk moves to XSS and to CORS. Once a server helper moves the session into a cookie, CSRF applies again.

The Supabase client sends an authorization header on every request, holding the session token or API key, and a single-page app that adds a JWT to its own requests works the same way. Two things take CSRF’s place. The token usually lives where script can read it, so XSS becomes the main risk; the storage and expiry trade-offs belong to JWT security. And CORS matters more, since a loose policy is what would let another origin’s script use a token it got hold of.

The moment a server-side helper moves the session into a cookie, the previous section applies to every route that trusts that cookie. Supabase’s server-side setup stores “the user session in cookies instead of local storage”, and Auth.js (NextAuth) keeps its session in an HttpOnly cookie under both its JWT and database strategies. A custom refresh-token cookie counts too.

How to configure CORS for production

A production CORS policy is an exact allow-list of full origins per environment, compared by string equality, with the matched origin echoed back, Vary: Origin set, and credentials allowed only when needed. A front end and API on one shared origin need no CORS headers at all.

These are my working rules, each tied to MDN where MDN states it. Hold the list in configuration per environment, with scheme, host and port in each entry. Echo back the origin that matched, never the raw header. Send Vary: Origin, which MDN says a server should include when the allowed origin changes with the request, so a cache never serves one origin’s header to another. Allow credentials only when a front end on another origin really needs cookies. List the methods and headers you use, and answer the preflight OPTIONS request fast with an Access-Control-Max-Age; MDN’s default is 5 seconds and its example uses 86400, with each browser capping the value at its own maximum. My working rule for preview deployments is an explicit pattern anchored to your own project’s preview domain, in non-production configuration only. django-cors-headers shows the anchored form, r"^https://\w+\.example\.com$".

The last column is my working rule; the other cells come from each project’s docs as they read on September 30, 2026.

StackWhere CORS is setThe default to replaceThe production form
ExpressThe cors middlewarecors() with no options sends Access-Control-Allow-Origin: *origin as a function or a list of exact strings; credentials: true only when needed
Next.jsheaders() in next.config, or the Route Handler’s responseThe docs example sets the header to "*" with the comment “Set your origin”One exact origin in config; for several, check Origin in the Route Handler and echo the match
Supabase Edge FunctionsThe corsHeaders the function returns, or the withSupabase wrapperThe hard-coded corsHeaders example sends 'Access-Control-Allow-Origin': '*'; restricting it is not stated in Supabase’s CORS guideA wildcard only on a public function; an exact origin on one that acts for a signed-in user
FastAPICORSMiddlewareRestrictive by default; allow_origins=['*'] allows any origin, and ['*'] cannot be used with allow_credentials=Trueallow_origins as an exact list
Djangodjango-cors-headers settingsCORS_ALLOW_ALL_ORIGINS = True, which its README says “can be dangerous”CORS_ALLOWED_ORIGINS as an exact list
nginx or the platform edgeadd_header in the server or location blockCORS is not stated in nginx’s headers module docsHeaders set in one place, the app or the edge, never both; add always if error responses must carry them

Setting headers in both the app and the edge breaks the response: MDN’s error page says more than one Access-Control-Allow-Origin header “isn’t allowed”, and browsers accept a single origin or null, never a list. The sources for the table are the Express cors middleware, Supabase’s CORS guide for Edge Functions, FastAPI’s CORS tutorial and django-cors-headers.

On Express, the allow-list with a log line for refused origins fits in eleven lines:

import cors from 'cors';
const allowed = new Set(process.env.CORS_ORIGINS.split(',')); // exact origins for this environment
app.use(cors({
  origin(origin, callback) {
    if (origin && allowed.has(origin)) return callback(null, origin); // echoes the match and adds Vary: Origin
    if (origin) console.warn('CORS: rejected origin', origin);
    callback(null, false); // no Access-Control-Allow-Origin header at all
  },
  methods: ['GET', 'POST', 'PATCH', 'DELETE'], maxAge: 86400,
  credentials: true, // only when a front end on another origin needs cookies
}));

CORS is not authorization: every route still checks who the caller is and what they may touch, which is broken access control territory. For a CORS error that shows up only after deploy, with two origins, a preflight and the credentials flag in play, start with why a CORS error appears only in production.

crossorigin, null origin and the CORS details

The crossorigin attribute makes the browser fetch a script or stylesheet with CORS, and anonymous sends no credentials cross-origin; Subresource Integrity and readable error reports need it. It is not an API security setting. The null origin can be sent by sandboxed and file pages, so allowing it allows almost anyone.

MDN’s crossorigin attribute reference lists it on audio, img, link, script and video, and without it CORS is not used at all. A script tag with crossorigin="anonymous" is there for two reasons. MDN says Subresource Integrity on a cross-origin file “must use the Cross-Origin Resource Sharing (CORS) protocol”, and a script loaded without the attribute passes “minimal information” to window.onerror, and MDN gives the attribute as the way to allow error logging for sites that use a separate domain for static media. Content-Security-Policy and SRI are their own topic: Content-Security-Policy without unsafe-inline.

Origin: null comes from more places than a test file. MDN’s Origin header reference lists file and data URLs, redirects across origins, and iframes sandboxed without allow-same-origin, among others. MDN’s Access-Control-Allow-Origin page says the value null “should not be used”, since “any origin can create a hostile document with a null origin”. Never add it to make local testing work: serve the test page from localhost and allow that origin in development only.

TermWhat it isWhat to do
crossorigin="anonymous"Fetch with CORS, no cookies or HTTP auth unless the file is same-originAdd it to cross-origin scripts that use SRI or need full error reports
crossorigin="use-credentials"Fetch with CORS, credentials always sentUse only when the file needs a login
Origin: nullAn opaque or privacy-sensitive origin: sandboxed frames, file and data URLs, cross-origin redirectsNever put it on an allow-list
PreflightThe OPTIONS request the browser sends before a request that is not simpleAnswer it fast, with Access-Control-Max-Age
Access-Control-Expose-HeadersWhich response headers script may readList only the ones your front end reads
Access-Control-Allow-OriginOne origin, or * for requests without credentialsEcho the matched origin; never a list

How to verify it

CSRF and CORS are verified with 6 requests against staging: an approved origin is echoed exactly, an unapproved origin gets no allow header, the null origin is refused, a look-alike domain is refused, a state-changing call without a token is refused and changes nothing, and no state-changing route answers GET.

  1. 01 Approved origin: send a request with Origin set to your real front end. Pass: Access-Control-Allow-Origin returns that exact origin. Fail: an asterisk, or a different value
  2. 02 Unapproved origin: send Origin: https://not-yours.example. Pass: no Access-Control-Allow-Origin header. Fail: the made-up origin comes back
  3. 03 Null origin: send Origin: null. Pass: no allow-origin header. Fail: null comes back
  4. 04 Look-alike origin: send an origin that ends with your domain name but belongs to nobody you know. Pass: no allow-origin header. Fail: it comes back
  5. 05 Cookie-session apps only: send a state-changing request with a valid session cookie and no CSRF token, then again with a cross-site Origin. Pass: a 4xx refusal both times and an unchanged row. Header-token apps record: not applicable, no ambient credential
  6. 06 Route list: write down every state-changing route. Pass: none of them answers to GET. Fail: any delete, unsubscribe or update that acts on a GET

The test request from an unapproved origin is the one that catches a reflected origin, and the two commands below run checks 2 and 3 against your own staging API:

# An origin you never listed: the reply must carry no Access-Control-Allow-Origin
curl -i -H "Origin: https://not-yours.example" https://staging.example.com/api/me
# The null origin: same expectation
curl -i -H "Origin: null" https://staging.example.com/api/me

On an app whose front end and API share one origin, checks 1 to 4 pass when no origin gets CORS headers at all. For check 5, record the status your framework returns rather than expecting one code; Django’s default is “403 Forbidden”, and you confirm nothing changed by reading the row before and after. Add one read on a cookie-session app: the session cookie’s attributes in the browser’s storage panel, showing SameSite, Secure and HttpOnly.

Keep the evidence: the six requests and their responses, saved with the date, the route list, and a screenshot of the cookie’s attributes. Keep the six as a cross-origin and CSRF checklist and run it again whenever a front-end domain is added or the auth library changes.

As a cross-site request forgery testing tool, ZAP (formerly OWASP ZAP) raises alerts in its passive scan named “Absence of Anti-CSRF Tokens” and “Cross-Domain Misconfiguration”, both rated Medium in its alert list. I’d use it as a second opinion: it cannot know which origins you meant to allow, and that is what the six requests check.

In the Production Hardening Sprint, deliverable 3.3, restricted cross-origin access, is verified this way: test approved and unapproved origins, credential handling, and state-changing request protections.

Where the sprint does this

Deliverable 3.3 is where we restrict CORS to intended origins and check credentialed request behavior alongside authentication and request-forgery protections, checked the way the last paragraph of the verify section says. The result goes into the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Formal third-party certifications and independent audit opinions are separate from these engineering deliverables. Every deliverable is listed in the published scope.

Common questions about CSRF and CORS

Is CSRF still a thing?

Yes, though less than it was. Chrome and Edge now default cookies to SameSite=Lax, which keeps the cookie off most cross-site POSTs, and OWASP files CSRF inside A01:2025 Broken Access Control instead of as its own category. It stays real for state-changing GET routes, cookies set to SameSite=None, and browsers with no Lax default, which on MDN’s data includes Safari and a default Firefox.

Is it safe to enable CORS?

Yes, when the policy names exact origins. The unsafe forms are a reflected origin with credentials and null on the allow-list; a wildcard is safe only for public responses that need no login, because browsers refuse to pair it with credentials.

Is there any way to bypass CORS?

Yes, by not using a browser: CORS is a rule browsers apply to their own requests, so curl, Postman or another server reaches your API whatever your CORS settings say. For an owner, a CORS unblock extension on someone’s machine changes nothing, because anyone with an HTTP client could already call you. That is why every route still needs its own authorization check.

How do I turn off CSRF?

Turn it off per route, never globally: exempt only routes that prove the caller another way, such as webhook endpoints that verify a provider signature, and leave it on everywhere a session cookie is trusted. In Django that is the csrf_exempt decorator on the one view.