CSRF against JSON APIs by abusing content-type parsing differences or CORS misconfigurations that allow cross-origin JSON submission.
TL;DR
text/plain Content-Type bypass defeats CORS preflightenctype="text/plain" in HTML forms delivers valid JSON bodies — no JavaScript or custom headers neededapplication/x-www-form-urlencoded, multipart/form-dataContent-Type: application/json server-side — reject text/plain and form-encoded on API endpointsJSON CSRF (CWE-352) attacks REST and GraphQL APIs that use application/json but fail to strictly enforce Content-Type on inbound requests. The widespread assumption is that JSON APIs are CSRF-immune because application/json triggers a CORS preflight OPTIONS request, which the browser requires the server to approve before sending the actual request. Without server approval, the browser blocks the cross-origin POST.
The assumption breaks down because CORS preflights are triggered by the Content-Type header, not the body format. An HTML form with enctype="text/plain" sends Content-Type: text/plain — a CORS simple type that requires no preflight. If the attacker crafts the form's input fields so the assembled POST body is valid JSON, the server receives a well-formed JSON payload with no CORS preflight and no browser-enforced block. Whether the attack succeeds depends entirely on whether the server validates Content-Type before parsing the body.
This is not a theoretical risk. HackerOne #245346 demonstrates it against WakaTime's production API. CVE-2022-41919 (Fastify) and CVE-2024-4994 (GitLab) document it across major frameworks. CVE-2025-68604 (WPGraphQL, published May 2026) shows the vulnerability persists in actively-maintained software years after the technique was published.
The text/plain bypass works because HTML forms support exactly three enctype values: application/x-www-form-urlencoded (default), multipart/form-data, and text/plain. All three are CORS simple request types that require no preflight. The body assembled by the browser from a text/plain form is:
<input name>= <input value>The attacker engineers the name and value to produce valid JSON when concatenated:
The text/plain JSON body construction:
<!-- The form trick: name + "=" + value = valid JSON body -->
<form method="POST" action="https://api.target.com/v1/transfer"
enctype="text/plain">
<!--
Browser assembles body as: {"to":"attacker","amount":5000,"x":"="}
name = '{"to":"attacker","amount":5000,"x":"'
value = '"}'
result = name + "=" + value = {"to":"attacker","amount":5000,"x":"="}
Most JSON parsers ignore the trailing garbage key "x": "="
-->
<input name='{"to":"attacker","amount":5000,"x":"' value='"}'>
</form>
<script>document.forms[0].submit();</script>The assembled POST body:
{"to":"attacker","amount":5000,"x":"="}This is valid JSON. The trailing "x":"=" is an innocuous garbage key that most parsers ignore. The attack requires: (1) the session cookie has SameSite=None or no SameSite attribute, (2) the server parses the body as JSON regardless of Content-Type, and (3) no custom header (e.g., X-CSRF-Token) is required.
Three JSON CSRF attack paths:
# Path 1: text/plain form submission (most reliable)
# - No preflight (text/plain is simple)
# - Body is valid JSON
# - Server ignores Content-Type and parses JSON
# Path 2: application/x-www-form-urlencoded body parsed as JSON
# (Express bodyParser.urlencoded + express-json-body route)
# POST body: email=attacker%40evil.com
# If server does: body = JSON.parse(req.body.toString()) -> fails
# If server does: req.body["email"] -> succeeds via bodyParser.urlencoded
# Path 3: CORS misconfiguration allows application/json
# If Access-Control-Allow-Origin: * AND Access-Control-Allow-Credentials: true
# (invalid per spec but some frameworks allow it)
# fetch('/api/transfer', {method:'POST', body: JSON.stringify({...}), credentials:'include'})| Variant | Mechanism | Conditions | CVE/Reference |
|---|---|---|---|
text/plain form body | Form enctype delivers JSON-shaped body | SameSite=None + server ignores Content-Type | H1 #245346, CVE-2024-24816 |
| GraphQL GET mutation | img tag pointing to /graphql?query=mutation{...} | Server accepts mutations via GET | H1 #1122408 (GitLab, $3,370) |
| GraphQL form-encoded | input field name="query" with mutation value | Apollo < 4 accepts urlencoded | CVE-2024-4994, CVE-2025-68604 |
| Fastify Content-Type | Wrong Content-Type accepted by framework | Fastify <= 4.10.1 parsing bug | CVE-2022-41919 |
| CORS wildcard + credentials | Access-Control-Allow-Origin: * + cookies | Server misconfigures CORS | Generic CORS CSRF |
multipart/form-data coercion | Multipart data parsed as JSON fields | Framework auto-parses multipart | API-specific, no preflight |
GraphQL CSRF payloads:
<!-- GraphQL GET mutation — zero-click, no JavaScript, no preflight -->
<!-- SameSite=Lax cookies are NOT sent for subresource <img> -->
<!-- Use for SameSite=None or absent cookies -->
<img src="https://api.target.com/graphql?query=mutation%7BdeleteAccount(id:123)%7Bstatus%7D%7D">
<!-- GraphQL form-encoded mutation — form POST, no preflight -->
<form action="https://api.target.com/graphql" method="POST">
<input name="query" value='mutation{updateEmail(email:"evil@attacker.com"){id}}'>
</form>
<script>document.forms[0].submit();</script>CVE-2024-4994 — GitLab CE/EE GraphQL (CVSS 8.1, High, June 2024): CSRF on /api/graphql allowed unauthenticated attackers to execute arbitrary GraphQL mutations on behalf of authenticated victims. GitLab's GraphQL endpoint accepted mutations via content types that did not trigger CORS preflight — specifically application/x-www-form-urlencoded and multipart/form-data. High confidentiality and integrity impact (C:H/I:H). Affected: GitLab 16.1.0–16.11.4, 17.0.0–17.0.2, 17.1.0. HackerOne #1122408 ($3,370 bounty) documented the GET-request mutation vector on the same endpoint.
CVE-2022-41919 — Fastify Content-Type Parsing (CVSS 4.2, Moderate): Fastify 4.x <= 4.10.1 and 3.x <= 3.29.3 improperly validated Content-Type on inbound requests. Attackers sent application/x-www-form-urlencoded, multipart/form-data, or text/plain requests that bypassed CORS preflight and invoked JSON-only API endpoints without token validation. Discovered by Ry0taK (HackerOne). Fixed in Fastify 4.10.2 and 3.29.4.
CVE-2025-68604 — WPGraphQL (CVSS 5.4, May 2026): WPGraphQL <= 2.5.3 allowed attackers to craft a remote page that caused the victim's authenticated browser to submit requests to the WordPress GraphQL endpoint, executing mutations: creating unauthorized user accounts, modifying content, changing plugin options, escalating privileges. Fixed in WPGraphQL 2.5.4+.
HackerOne #245346 — WakaTime JSON CSRF ($500): The text/plain enctype bypass against WakaTime's POST /heartbeats endpoint. The server parsed the text/plain body as JSON without checking Content-Type, accepted it without a CSRF token, and recorded the heartbeat data. This is a textbook demonstration of the text/plain JSON body attack pattern against a widely-used developer productivity tool.
CVE-2024-24816 — CKEditor 5 Upload Adapter (CVSS 6.1): CSRF via text/plain Content-Type on CKEditor's upload adapter endpoint. The server read the raw body and parsed it as JSON regardless of Content-Type, and no CORS preflight was triggered. Attacker-controlled file uploads executed in the victim's context.
Content-Type: application/json to Content-Type: text/plain. Resend the request. If the server responds with success (2xx), it accepts text/plain bodies.Origin: https://evil.com to the request. Check the response for Access-Control-Allow-Origin: * or Access-Control-Allow-Origin: https://evil.com. Combined with step 2, this confirms cross-origin exploitability.SameSite attribute. If SameSite=None or absent, the cookie is sent cross-origin and the attack is fully exploitable.?query=mutation{...}). If the server returns 200 with data, GET mutations are enabled and directly CSRF-exploitable via <img> tag (no preflight).application/x-www-form-urlencoded: submit query=mutation{...} as a standard form field. Many GraphQL implementations accept this format.text/plain form PoC targeting the most impactful state-change operation and verify it executes in a browser with a victim session.OWASP ZAP's active scan rule 20012 identifies some JSON CSRF scenarios. Burp Suite Pro's CSRF scanner does not automatically test Content-Type downgrades — manual Repeater testing is required. BreachVex probes each scoped POST/PUT/DELETE endpoint with a text/plain Content-Type and Origin: https://evil.com, confirming exploitability by checking both the HTTP status code and the CORS response headers against the evil origin.
The root cause is the server accepting and parsing bodies regardless of Content-Type. Enforce strict Content-Type validation before any body parsing.
# FastAPI — strict Content-Type enforcement
from fastapi import Request, HTTPException
async def enforce_json_content_type(request: Request):
content_type = request.headers.get("Content-Type", "")
if request.method not in ("GET", "HEAD", "OPTIONS"):
if not content_type.startswith("application/json"):
raise HTTPException(
status_code=415,
detail="Unsupported Media Type. API requires application/json."
)// Express.js — reject non-JSON content types on API routes
app.use('/api', (req, res, next) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const ct = req.headers['content-type'] || '';
if (!ct.includes('application/json')) {
return res.status(415).json({ error: 'API requires Content-Type: application/json' });
}
}
next();
});// Apollo Server 3.7+ — enforce CSRF prevention header (Apollo-Require-Preflight)
// This causes non-preflight requests to be rejected at the GraphQL layer
const server = new ApolloServer({
typeDefs,
resolvers,
csrfPrevention: true, // Default true in Apollo Server 3.7+
// csrfPrevention sends 400 for requests lacking a non-simple header
});For stateless architectures where session state is unavailable server-side, use an HMAC-signed double-submit pattern:
import hmac, hashlib, secrets, base64
SECRET_KEY = os.environ["CSRF_SECRET_KEY"]
def generate_csrf_cookie(session_id: str) -> str:
nonce = secrets.token_urlsafe(16)
mac = hmac.new(SECRET_KEY.encode(), f"{session_id}:{nonce}".encode(), hashlib.sha256)
return base64.urlsafe_b64encode(mac.digest() + nonce.encode()).decode()
def validate_csrf_double_submit(session_id: str, cookie_val: str, header_val: str) -> bool:
if not cookie_val or not header_val or cookie_val != header_val:
return False
# Verify HMAC — prevents subdomain cookie injection
try:
raw = base64.urlsafe_b64decode(header_val)
mac_bytes, nonce = raw[:32], raw[32:].decode()
expected = hmac.new(SECRET_KEY.encode(), f"{session_id}:{nonce}".encode(), hashlib.sha256)
return hmac.compare_digest(expected.digest(), mac_bytes)
except Exception:
return FalseA naive double-submit cookie without HMAC binding is vulnerable to subdomain injection. If an attacker controls sub.target.com, they can set a cookie with domain=.target.com matching the parameter value they submit. Only HMAC-bound tokens prevent this attack chain.
No. JSON APIs are commonly assumed to be CSRF-immune because application/json triggers a CORS preflight. However, attackers bypass this using the text/plain enctype trick: an HTML form with enctype='text/plain' sends a POST with a JSON-shaped body without triggering CORS preflight. If the server reads the raw body as JSON regardless of Content-Type, the attack succeeds.
An HTML form with enctype='text/plain' sends Content-Type: text/plain—a CORS simple request requiring no preflight. By crafting the form's input name and value so the assembled body is valid JSON (e.g., name='{"email":"x@evil.com","x":"' value='"}'), the attacker delivers a JSON body without triggering the CORS preflight that would block application/json.
CVE-2022-41919 (Fastify, CVSS 4.2) — improper Content-Type validation allowed attackers to send application/x-www-form-urlencoded or text/plain requests to invoke JSON-only API endpoints, bypassing CORS preflight. CVE-2024-4994 (GitLab GraphQL, CVSS 8.1) — CSRF on the /api/graphql endpoint executing arbitrary mutations via content types that do not trigger CORS preflight.
GraphQL CSRF exploits the fact that GraphQL's single /graphql endpoint accepts mutations via GET requests (query string) and via application/x-www-form-urlencoded or multipart/form-data—all CORS simple requests. An attacker submits mutations via a standard HTML form or an <img> tag pointing to a GET mutation URL, bypassing CORS preflight entirely.
Modern Apollo Server (v3.7+ / Apollo Router) enables CSRF prevention by default via the Apollo-Require-Preflight header requirement. Older versions and self-configured setups may not. CVE-2025-68604 (WPGraphQL ≤ 2.5.3, CVSS 5.4) demonstrates a GraphQL endpoint still vulnerable to CSRF in 2026.
Intercept a JSON API request in Burp. In Repeater, change Content-Type from application/json to text/plain and re-send. If the server responds with 200/201/204, it accepts text/plain and likely parses the body as JSON regardless of Content-Type. Next, craft an HTML PoC form with enctype='text/plain' to confirm cross-origin exploitability in a browser.
HackerOne #245346 is a public JSON CSRF report against WakaTime's POST /heartbeats endpoint. The attacker sent a POST request with enctype='text/plain' and a JSON-shaped body. WakaTime's server parsed the text/plain body as JSON, accepted it without a CSRF token, and recorded the heartbeat—demonstrating that widely-deployed development tools were vulnerable to this bypass.
BreachVex identifies cookies with SameSite=None or absent, finds POST/PUT/DELETE endpoints in scope, resends with Content-Type: text/plain and Origin: https://evil.com, and confirms if the response is 200/201/204 with Access-Control-Allow-Origin reflecting evil.com or *. Endpoints returning success without a CSRF token are reported as JSON CSRF findings.