Back to Blog
critical SEVERITY7 min read

How CSRF Protection Failures Happen in FastAPI and How to Fix Them

A critical CORS misconfiguration in `backend/main.py` allowed cookies to be sent alongside wildcard-origin requests, violating the CORS specification and opening the door to cross-site request forgery attacks. The fix conditionally disables `allow_credentials` when the allowed origins list contains a wildcard, bringing the configuration into compliance with browser security rules. This change closes a subtle but dangerous gap that could have let attackers on sibling subdomains forge authenticate

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This vulnerability is a CSRF/CORS misconfiguration (CWE-352) in a Python FastAPI application. The `CORSMiddleware` in `backend/main.py` was configured with both `allow_origins=["*"]` and `allow_credentials=True` simultaneously — a combination the CORS specification explicitly forbids and browsers reject, while also creating a permissive security posture for same-site attacks. The fix introduces a computed boolean `_allow_credentials` that disables credential sharing whenever a wildcard origin is present, ensuring the application never advertises credential-bearing cross-origin access to untrusted origins.

Vulnerability at a Glance

cweCWE-352
fixCompute `_allow_credentials` dynamically — only `True` when no wildcard origin is present
riskAttackers on sibling subdomains can forge authenticated state-changing requests using the victim's session cookies
languagePython
root cause`allow_credentials=True` hardcoded alongside `allow_origins=["*"]` in FastAPI CORSMiddleware
vulnerabilityCSRF via permissive CORS configuration

How CSRF Protection Failures Happen in FastAPI and How to Fix Them

The backend/main.py file is the front door of this web application — it handles routing, middleware, and the security boundaries that separate legitimate users from attackers. But a subtle misconfiguration in its CORS middleware setup created a real-world exploitable path for cross-site request forgery (CSRF) attacks. This post walks through exactly what went wrong, how it could be exploited, and the precise change that closed the gap.


The Vulnerability Explained

A CORS Rule You Cannot Break

The CORS specification is unambiguous on one point: you cannot combine a wildcard origin (*) with credentialed requests. When allow_credentials=True is set, the browser requires the server to respond with a specific origin (e.g., https://notes.example.com) in the Access-Control-Allow-Origin header — not a wildcard. Browsers will outright reject the combination.

Here is the vulnerable configuration that existed in backend/main.py around line 204:

# BEFORE (vulnerable)
allowed_origins = config.get('server', {}).get('allowed_origins', ["*"])
app.add_middleware(
    CORSMiddleware,
    allow_origins=allowed_origins,   # defaults to ["*"]
    allow_credentials=True,          # hardcoded — always True
    allow_methods=["*"],
    allow_headers=["*"],
)

The problem is the hardcoded allow_credentials=True. When the application is deployed with the default configuration (i.e., allowed_origins is not explicitly set in config.yaml), allowed_origins becomes ["*"]. At that point:

  1. The CORS spec forbids the combination — browsers will block the preflight.
  2. More dangerously, the intent of the developer was clearly to allow credentials, meaning session cookies are expected to be sent with cross-origin requests.
  3. If an operator later restricts allowed_origins to specific domains but forgets to audit allow_credentials, the credential-bearing behavior silently persists.

Why SameSite=Lax Isn't a Complete Defense

The application uses session cookies with SameSite=Lax. This setting blocks cross-origin POST requests from third-party sites — but it has two important blind spots:

  • Same-site attacks: A subdomain like evil.example.com is considered "same-site" relative to notes.example.com. An attacker who can serve content on any subdomain of the same registrable domain can submit POST requests that do include the victim's session cookie.
  • Top-level navigation GET requests: SameSite=Lax permits cookies on GET requests triggered by top-level navigation (e.g., clicking a link), which means any endpoint that changes state via GET is still vulnerable.

The Attack Scenario

Imagine this application is deployed at app.company.com. An attacker finds or creates a page at promo.company.com (a marketing subdomain with less rigorous security). They embed the following in that page:

<form method="POST" action="https://app.company.com/api/admin/delete-user">
  <input type="hidden" name="user_id" value="42" />
</form>
<script>document.forms[0].submit();</script>

Because promo.company.com and app.company.com share the company.com registrable domain, the browser treats this as a same-site request and includes the session cookie. The server receives a fully authenticated DELETE request that the user never intended to make.


The Fix

The fix is elegant in its simplicity. Instead of hardcoding allow_credentials=True, the value is now computed based on whether the origins list contains a wildcard:

# AFTER (fixed)
allowed_origins = config.get('server', {}).get('allowed_origins', ["*"])

# Credentials must not be sent with wildcard origins (CORS spec disallows it and
# browsers reject it; explicitly disable to avoid misconfiguration).
_allow_credentials = "*" not in allowed_origins

app.add_middleware(
    CORSMiddleware,
    allow_origins=allowed_origins,
    allow_credentials=_allow_credentials,  # dynamically computed
    allow_methods=["*"],
    allow_headers=["*"],
)

Before vs. After

Configuration State Before After
allowed_origins = ["*"] (default) allow_credentials=True allow_credentials=False
allowed_origins = ["https://app.example.com"] allow_credentials=True allow_credentials=True

Why This Change Works

The single line _allow_credentials = "*" not in allowed_origins encodes the CORS specification's own rule directly into the middleware configuration. It makes two guarantees:

  1. Wildcard deployments are safe by default. When no explicit origins are configured, credentials are disabled. An unauthenticated CORS policy is still useful for public API endpoints that don't require session state.
  2. Explicit origin lists preserve authenticated behavior. Operators who restrict allowed_origins to known domains (the secure, production-ready configuration) automatically get credential support without any additional changes.

This is a defense-in-depth improvement: it doesn't replace CSRF tokens (which remain the gold standard for protecting state-changing endpoints), but it eliminates a configuration path that was both spec-violating and operationally dangerous.


Prevention & Best Practices

1. Never Hardcode allow_credentials=True Alongside Wildcard Origins

The CORS spec forbids it, browsers reject it, and it signals a confused security model. Always derive allow_credentials from whether your origins list is restrictive.

2. Implement CSRF Tokens for State-Changing Endpoints

Even with correct CORS configuration, CSRF tokens remain the most robust defense. In FastAPI, libraries like fastapi-csrf-protect make this straightforward:

from fastapi_csrf_protect import CsrfProtect

@app.post("/api/delete-user")
async def delete_user(csrf_protect: CsrfProtect = Depends()):
    csrf_protect.validate_csrf_in_cookies(request)
    # ... proceed with deletion

3. Prefer SameSite=Strict Over SameSite=Lax for Sensitive Sessions

SameSite=Strict prevents cookies from being sent on any cross-site navigation, including top-level GET requests. The tradeoff is a slightly worse user experience (users get logged out when clicking links from emails), but for admin interfaces or financial applications, it's worth it.

4. Audit Your CORS Configuration in CI

Add a Semgrep rule to your CI pipeline to catch this pattern before it ships:

# semgrep rule sketch
rules:
  - id: fastapi-cors-wildcard-with-credentials
    patterns:
      - pattern: |
          app.add_middleware(CORSMiddleware, ..., allow_origins=[...,"*",...], ...,
                             allow_credentials=True, ...)
    message: "Wildcard CORS origin with allow_credentials=True violates the CORS spec"
    severity: ERROR

5. Follow OWASP Guidance

The OWASP Cross-Site Request Forgery Prevention Cheat Sheet is the definitive resource. Key recommendations include:
- Use synchronizer token patterns for all state-changing operations.
- Validate the Origin and Referer headers as a secondary defense.
- Avoid state-changing GET endpoints entirely.


Key Takeaways

  • allow_credentials=True with allow_origins=["*"] is never safe — it violates the CORS specification and creates a misleading security posture, even if browsers technically reject the combination.
  • SameSite=Lax is not CSRF-proof — same-site subdomain attacks bypass it entirely, which is especially relevant for multi-tenant deployments on shared domains.
  • Default configurations are attack surfaces — the config.get('server', {}).get('allowed_origins', ["*"]) pattern means the wildcard is the default behavior, making hardcoded allow_credentials=True dangerous for any operator who doesn't read the docs.
  • Derive security settings from other security settings — the fix's _allow_credentials = "*" not in allowed_origins pattern is a great model: make the safe behavior automatic rather than requiring operators to configure two related values consistently.
  • CORS misconfiguration and CSRF are distinct but related — fixing CORS doesn't eliminate CSRF risk; CSRF tokens on state-changing endpoints remain the defense of last resort.

How Orbis AppSec Detected This

  • Source: The allowed_origins variable, populated from config.yaml with a default of ["*"], fed directly into the CORSMiddleware constructor in backend/main.py.
  • Sink: The allow_credentials=True hardcoded argument to app.add_middleware(CORSMiddleware, ...) at line 204 of backend/main.py, which unconditionally advertised credential-bearing cross-origin access regardless of the origin policy.
  • Missing control: No conditional logic existed to disable allow_credentials when the origins list was a wildcard; the two settings were configured independently with no enforcement of the CORS specification's mutual exclusivity rule.
  • CWE: CWE-352 — Cross-Site Request Forgery
  • Fix: A single computed boolean _allow_credentials = "*" not in allowed_origins was introduced and substituted for the hardcoded True, making credential-bearing CORS access contingent on an explicit, non-wildcard origin allowlist.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

This vulnerability is a textbook example of how two individually reasonable-seeming configuration choices — "allow all origins for self-hosted simplicity" and "enable credentials for session-based auth" — combine into a dangerous misconfiguration. The fix required exactly one new line of code and zero changes to application logic, yet it closes a real attack path against same-site subdomain attackers.

For developers building FastAPI applications with session-based authentication, the lesson is clear: treat your CORS and cookie configurations as a system, not independent knobs. When you change one, audit the other. And when in doubt, default to the most restrictive behavior — it's much easier to loosen security for a specific deployment than to patch a breach.


References

Frequently Asked Questions

What is a CSRF vulnerability?

Cross-Site Request Forgery (CSRF) tricks an authenticated user's browser into sending an unintended request to a web application, potentially performing state-changing actions (like deleting data or changing settings) without the user's knowledge.

How do you prevent CSRF in Python FastAPI?

Use CSRF tokens on state-changing endpoints, set `SameSite=Strict` or `SameSite=Lax` on session cookies, and never combine `allow_credentials=True` with `allow_origins=["*"]` in your CORSMiddleware configuration.

What CWE is CSRF?

CSRF is classified as CWE-352: Cross-Site Request Forgery.

Is SameSite=Lax enough to prevent CSRF?

No. SameSite=Lax blocks most cross-origin POST requests but does not protect against attacks originating from the same registrable domain (e.g., a sibling subdomain like `evil.example.com` attacking `app.example.com`), nor against top-level navigation GET requests that change state.

Can static analysis detect CSRF misconfigurations?

Yes. Tools like Semgrep can flag patterns where `allow_credentials=True` is paired with wildcard origins in middleware configuration, and AI-assisted scanners like Orbis AppSec can reason about the combined security posture of CORS and cookie settings.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #278

Related Articles

high

How Dependabot Missing Cooldown happens in GitHub Actions and how to fix it

A high-severity misconfiguration in `.github/dependabot.yml` left this Node.js library without a cooldown period, meaning Dependabot would immediately propose updates to newly published packages — including potentially malicious or unstable ones. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` package ecosystem entries, introducing a mandatory 7-day waiting period before any new package version is surfaced as an update candidate.

critical

How Missing Rate Limiting Happens in Node.js SSE Handlers and How to Fix It

A critical missing rate-limiting control in `src/sse/handlers/chat.js` allowed any caller to flood the SSE chat endpoint with unlimited requests, risking server resource exhaustion, denial of service, and runaway AI provider API costs. The fix introduces a per-IP sliding-window rate limiter that caps requests at 60 per minute and returns HTTP 429 on violations. Because the endpoint was publicly reachable and only validated API keys — not request frequency — exploitation required nothing more tha

medium

How Denial of Service via Catastrophic Backtracking happens in Node.js and how to fix it

CVE-2026-4867 is a Denial of Service vulnerability in path-to-regexp 0.1.12 where malformed URL parameters can trigger catastrophic backtracking in the library's regular expression engine, allowing an attacker to hang or crash a Node.js application with a single crafted request. The fix upgrades path-to-regexp to version 0.1.13, which patches the vulnerable regex patterns. This change was applied via a package-level override to ensure the patched version is used throughout the entire dependency

high

How Denial of Service via Exponential-Time Complexity happens in Node.js and how to fix it

CVE-2026-13149 is a high-severity Denial of Service vulnerability in the `brace-expansion` npm package, where crafted input strings trigger exponential-time processing that can freeze or crash a Node.js application. The fix upgrades `brace-expansion` from `2.0.2` to `2.1.4` and `minimatch` from `5.1.6` to `5.1.9`, along with npm `overrides` to ensure the patched versions are used throughout the entire dependency tree.

critical

How Unrestricted File Upload happens in Node.js/Express and how to fix it

A critical unrestricted file upload vulnerability was discovered in `mainsystem/routes/admin/profile.js`, where the avatar upload endpoint accepted any file type without validation. An authenticated attacker could upload a malicious server-side script to a web-accessible directory and execute arbitrary code on the server. The fix adds MIME type filtering, an allowlist of safe image formats, and a 2 MB file size limit to the multer middleware.

critical

How eval() Code Injection happens in JavaScript and how to fix it

A critical code injection vulnerability was discovered in `js/lib/jsencrypt.js` at line 195, where a direct `eval()` call executed a JavaScript string shim for the `process` object in browser environments. If an attacker could influence the string passed to `eval()`—through a compromised dependency, a man-in-the-middle attack, or supply chain tampering—they could achieve arbitrary JavaScript execution in any user's browser. The fix replaces the `eval()` call with the equivalent inline JavaScript