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.
| Question | Same-origin policy | CORS | CSRF defenses |
|---|---|---|---|
| Who enforces it | The browser | The browser, reading your response headers | Your server, plus the browser for SameSite |
| What it stops | Another origin’s script reading your responses | Nothing; it lets the origins you name read responses | A cross-site request changing state |
| What it does not stop | The request reaching your server | Clients that are not browsers, such as curl or another server | Script already running on your own origin (XSS) |
| Where it is set | Built into the browser | Response headers from your API or your edge | Cookie 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 login | Does classic CSRF apply? | What to do |
|---|---|---|
| Session in a cookie (Django, Rails, Supabase’s server-side setup, Auth.js/NextAuth) | Yes | SameSite, 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 ride | Guard against XSS and keep CORS exact |
| Both at once | Yes | Treat 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 does | Why the builder wrote it | What another site can now do |
|---|---|---|
Copies whatever Origin arrives into Access-Control-Allow-Origin and sends Access-Control-Allow-Credentials: true | The wildcard below failed with credentials, and this makes the error go away | Read authenticated responses as the logged-in user |
Sends Access-Control-Allow-Origin: * with credentials | Copied from a starter example | Nothing yet: the browser blocks the response and reports a CORS error |
Matches origins with endsWith("yourapp.com") or a regular expression with no anchors | To let subdomains or previews through | Pass the check from a look-alike domain someone else registers |
Puts null on the allow-list | To make a local file or sandboxed test page work | Pass 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 form | Trigger 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 it | An embedded widget or a front end on another domain stopped working | Send 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.
- 01 No state change on GET, ever: deletes, unsubscribes and updates take POST, PUT, PATCH or DELETE
- 02 Session cookie set with SameSite=Lax or Strict, plus Secure and HttpOnly, written out explicitly
- 03 The framework's built-in CSRF token on every state-changing request
- 04 A server-side check that rejects a state-changing request whose Origin is not yours, or whose Sec-Fetch-Site is cross-site
- 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.
| Stack | Where CORS is set | The default to replace | The production form |
|---|---|---|---|
| Express | The cors middleware | cors() with no options sends Access-Control-Allow-Origin: * | origin as a function or a list of exact strings; credentials: true only when needed |
| Next.js | headers() in next.config, or the Route Handler’s response | The 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 Functions | The corsHeaders the function returns, or the withSupabase wrapper | The hard-coded corsHeaders example sends 'Access-Control-Allow-Origin': '*'; restricting it is not stated in Supabase’s CORS guide | A wildcard only on a public function; an exact origin on one that acts for a signed-in user |
| FastAPI | CORSMiddleware | Restrictive by default; allow_origins=['*'] allows any origin, and ['*'] cannot be used with allow_credentials=True | allow_origins as an exact list |
| Django | django-cors-headers settings | CORS_ALLOW_ALL_ORIGINS = True, which its README says “can be dangerous” | CORS_ALLOWED_ORIGINS as an exact list |
| nginx or the platform edge | add_header in the server or location block | CORS is not stated in nginx’s headers module docs | Headers 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.
| Term | What it is | What to do |
|---|---|---|
crossorigin="anonymous" | Fetch with CORS, no cookies or HTTP auth unless the file is same-origin | Add it to cross-origin scripts that use SRI or need full error reports |
crossorigin="use-credentials" | Fetch with CORS, credentials always sent | Use only when the file needs a login |
Origin: null | An opaque or privacy-sensitive origin: sandboxed frames, file and data URLs, cross-origin redirects | Never put it on an allow-list |
| Preflight | The OPTIONS request the browser sends before a request that is not simple | Answer it fast, with Access-Control-Max-Age |
Access-Control-Expose-Headers | Which response headers script may read | List only the ones your front end reads |
Access-Control-Allow-Origin | One origin, or * for requests without credentials | Echo 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.
- 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
- 02 Unapproved origin: send Origin: https://not-yours.example. Pass: no Access-Control-Allow-Origin header. Fail: the made-up origin comes back
- 03 Null origin: send Origin: null. Pass: no allow-origin header. Fail: null comes back
- 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
- 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
- 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.
The checks in this guide show you where the app is open. The sprint below closes those gaps, tests the result and writes the evidence down.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase