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:
- Execute mutations using an authenticated user's credentials
- Extract timing information from queries (XS-Search attacks)
- Abuse any GraphQL operations that have side effects
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-data
will 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/graphql
Note 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-Preflight
CSRF 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 preflight
While 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-data- A custom header
import 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) // 200When 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.
- Check your client - Ensure it sends
Content-Type: application/jsonfor POST requests - Add required headers - For GET requests, add
mercurius-require-preflight: true - Configure custom headers - If needed, add your client's headers to
requiredHeaders
For 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
]
}
})- Applications with authentication/authorization
- APIs that perform mutations or have side effects
- Public-facing GraphQL endpoints
- Applications handling sensitive data
- Public read-only APIs with no authentication
- Internal APIs on isolated networks
- Development environments (consider disabling temporarily)
- Keep CSRF prevention enabled in production
- Use HTTPS to prevent header manipulation
- Implement proper CORS policies as an additional layer
- Monitor for blocked requests to catch client issues
- Test thoroughly when adding custom required headers
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()