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:
- The CORS spec forbids the combination — browsers will block the preflight.
- More dangerously, the intent of the developer was clearly to allow credentials, meaning session cookies are expected to be sent with cross-origin requests.
- If an operator later restricts
allowed_originsto specific domains but forgets to auditallow_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.comis considered "same-site" relative tonotes.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=Laxpermits 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:
- 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.
- Explicit origin lists preserve authenticated behavior. Operators who restrict
allowed_originsto 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=Truewithallow_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 hardcodedallow_credentials=Truedangerous for any operator who doesn't read the docs. - Derive security settings from other security settings — the fix's
_allow_credentials = "*" not in allowed_originspattern 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_originsvariable, populated fromconfig.yamlwith a default of["*"], fed directly into theCORSMiddlewareconstructor inbackend/main.py. - Sink: The
allow_credentials=Truehardcoded argument toapp.add_middleware(CORSMiddleware, ...)at line 204 ofbackend/main.py, which unconditionally advertised credential-bearing cross-origin access regardless of the origin policy. - Missing control: No conditional logic existed to disable
allow_credentialswhen 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_originswas introduced and substituted for the hardcodedTrue, 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.