docs/security/csrf-prevention.md
Mercurius includes built-in Cross-Site Request Forgery (CSRF) prevention to protect your GraphQL endpoints from malicious requests.
Cross-Site Request Forgery (CSRF) attacks exploit the fact that browsers automatically include cookies and other credentials when making requests to websites. An attacker can create a malicious website that makes requests to your GraphQL server using the victim's credentials.
CSRF attacks are particularly dangerous for "simple" requests that don't trigger a CORS preflight check. These attacks can:
Mercurius protects against CSRF attacks by ensuring that GraphQL requests do not qualify as “simple” requests under the CORS specification.
A request is considered safe if any of the following conditions are met:
Requests that include a Content-Type header specifying a type other than:
text/plainapplication/x-www-form-urlencodedmultipart/form-datawill trigger a preflight OPTIONS request, meaning the request cannot be considered “simple.”
By default, Mercurius allows the following Content-Type headers:
application/json (recommended and most common)application/graphqlNote charset and other params are ignored
Requests that include a custom header also require a preflight OPTIONS request, preventing them from being “simple.”
By default, Mercurius checks for one of the following headers:
X-Mercurius-Operation-NameMercurius-Require-PreflightCSRF prevention is disabled by default. Enable it with:
const app = Fastify()
await app.register(mercurius, {
schema,
resolvers,
csrfPrevention: true // Enable with default settings
})
Default required headers (case insensitive):
x-mercurius-operation-name - Custom header for identifying GraphQL operationsmercurius-require-preflight - General-purpose header for forcing preflightWhile not strictly necessary, CORS should be configured appropriately:
await app.register(require('@fastify/cors'), {
origin: ['https://your-frontend.com']
})
await app.register(mercurius, {
schema,
resolvers,
csrfPrevention: true
})
Configure which headers are accepted to bypass CSRF protection (these replace the default headers):
await app.register(mercurius, {
schema,
resolvers,
csrfPrevention: {
contentTypes: ['application/json', 'application/graphql', 'application/vnd.api+json'],
requiredHeaders: ['Authorization', 'X-Custom-Header', 'X-Another-Header']
}
})
await app.register(mercurius, {
schema,
resolvers,
csrfPrevention: false
})
File uploads require a multipart/form-data request. To enable CSRF protection for file uploads, the request must include both:
Content-Type: multipart/form-dataimport mercuriusUpload from 'mercurius-upload';
import mercurius from 'mercurius';
await app.register(mercuriusUpload);
await app.register(mercurius, {
schema,
resolvers,
csrfPrevention: {
contentTypes: ['application/json', 'multipart/form-data'],
requiredHeaders: ['X-Custom-Header']
}
});
This configuration ensures that file uploads trigger a preflight OPTIONS request, preventing them from being treated as "simple" requests and keeping your API safe from CSRF attacks.
For custom GraphQL clients, ensure your requests include one of the following:
fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ query: '{ hello }' })
})
fetch('/graphql?query={hello}', {
method: 'GET',
headers: {
'mercurius-require-preflight': 'true'
}
})
const Fastify = require('fastify')
const mercurius = require('mercurius')
const app = Fastify({ logger: true })
const schema = `
type Query {
hello: String
users: [User]
}
type Mutation {
createUser(name: String!): User
}
type User {
id: ID!
name: String!
}
`
const resolvers = {
Query: {
hello: () => 'Hello World',
users: () => [{ id: '1', name: 'John' }]
},
Mutation: {
createUser: (_, { name }) => ({ id: Date.now().toString(), name })
}
}
// Register CORS (recommended)
await app.register(require('@fastify/cors'), {
origin: ['https://your-frontend.com'],
credentials: true
})
// Register Mercurius with CSRF protection
await app.register(mercurius, {
schema,
resolvers,
csrfPrevention: true, // Enable CSRF protection
})
await app.listen({ port: 4000, host: '0.0.0.0' })
console.log('GraphQL server running on http://localhost:4000/graphql')
// React/Frontend example with proper headers
const client = {
query: async (query, variables = {}) => {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Optional: Add custom identification
'x-mercurius-operation-name': 'ClientQuery'
},
body: JSON.stringify({ query, variables })
})
if (!response.ok) {
throw new Error(`GraphQL Error: ${response.status}`)
}
return response.json()
}
}
// Usage
try {
const result = await client.query('{ hello }')
console.log(result.data.hello)
} catch (error) {
console.error('CSRF or other error:', error)
}
// This request will be blocked (400 status)
const response = await fetch('/graphql?query={hello}', {
method: 'GET'
// No required headers or valid content-type
})
console.log(response.status) // 400
// This request will succeed
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ query: '{ hello }' })
})
console.log(response.status) // 200
When a request is blocked by CSRF prevention, you'll receive a 400 status with the following error:
{
"data": null,
"errors": [{
"message": "This operation has been blocked as a potential Cross-Site Request Forgery (CSRF)."
}]
}
If you're adding CSRF prevention to an existing Mercurius application:
✅ No action required - Most GraphQL clients already send appropriate headers.
Content-Type: application/json for POST requestsmercurius-require-preflight: truerequiredHeadersFor clients that can't be easily updated:
await app.register(mercurius, {
schema,
resolvers,
csrfPrevention: {
requiredHeaders: [
'x-mercurius-operation-name',
'mercurius-require-preflight',
'User-Agent', // Many clients send this automatically
'X-Requested-With' // Common in AJAX libraries
]
}
})
Q: My requests are being blocked with a 400 error
A: Ensure your client sends Content-Type: application/json or add mercurius-require-preflight: true header.
Q: GraphiQL stopped working A: GraphiQL should work automatically. If not, check if you've misconfigured the routes or added overly restrictive headers.
Q: My frontend or mobile app requests are blocked
A: Check the HTTP client configuration. Most modern clients work automatically, but ensure proper Content-Type headers.
Q: I need to support a legacy client
A: Add the client's existing headers to requiredHeaders, or as a last resort, disable CSRF prevention.
To debug CSRF prevention issues, you can temporarily log requests:
app.addHook('preHandler', async (request, reply) => {
if (request.url.includes('/graphql')) {
console.log('GraphQL request headers:', request.headers)
console.log('Content-Type:', request.headers['content-type'])
}
})
Create a simple test to verify CSRF protection is working:
// test-csrf.js
const test = async () => {
// This should be blocked
try {
const blocked = await fetch('http://localhost:4000/graphql?query={hello}')
console.log('CSRF test failed - request was not blocked:', blocked.status)
} catch (error) {
console.log('CSRF correctly blocked the request')
}
// This should work
try {
const allowed = await fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ hello }' })
})
console.log('Valid request succeeded:', allowed.status === 200)
} catch (error) {
console.log('Valid request failed:', error.message)
}
}
test()