GraphQL APIs built with Node.js fail security reviews for a predictable set of reasons: unbounded query depth, missing field-level authorization, introspection left enabled in production, and resolvers that pass user input straight into a database driver. A single crafted query with five nested connections can force a server to execute thousands of database calls from one HTTP request — the same 200-byte payload that a REST endpoint would reject outright with a 404. OWASP formalized this class of risk in API4:2023 (Unrestricted Resource Consumption), and Apollo made CSRF prevention a default in Apollo Server 4 specifically because so many teams had shipped schemas without it. This post walks through the controls a Node.js GraphQL API needs before it reaches production: query complexity limits, field-level authorization, injection-safe resolvers, introspection and CSRF hardening, and dependency hygiene across the graphql and apollo-server package families.
Why is GraphQL more exposed to resource-exhaustion attacks than REST?
Because a single GraphQL query lets a client compose arbitrary depth and breadth into one request, turning one HTTP call into a combinatorial explosion of backend work. A REST API exposes fixed endpoints — /users/:id/orders returns one resource shape, one query plan. GraphQL lets a client ask for user { orders { items { product { reviews { author { orders { ... } } } } } } } seven levels deep, and if each level fans out to 20 child records, the server ends up resolving on the order of 20^7 (1.28 billion) field resolutions from a single request. This is the mechanism behind the "GraphQL bomb." The fix is to cap both dimensions explicitly: graphql-depth-limit rejects queries past a fixed nesting level (a limit of 6-8 is typical for most product schemas), and graphql-query-complexity or graphql-cost-analysis assigns a point cost to each field and rejects any query exceeding a budget — commonly set between 500 and 1,000 points per request. Pair both with a per-IP request rate limit (express-rate-limit at 100 requests per 15 minutes is a common starting point) since complexity limits alone don't stop high-volume simple queries.
How do you enforce authorization when every field is a potential entry point?
You enforce it inside resolvers, at the field level, because gateway-level route middleware only protects the single /graphql endpoint — not the 200+ fields behind it. In a REST API, /admin/users can sit behind middleware that returns 403 before a handler ever runs. In GraphQL, adminUsers is just one field among dozens reachable through the same endpoint, so a check on the endpoint does nothing to stop an authenticated low-privilege user from querying it directly. The standard pattern is a permission layer evaluated per field: libraries like graphql-shield or a custom @auth(requires: ADMIN) schema directive intercept resolution before the resolver runs. Test this by writing at least one negative test per sensitive field — a 2022 study of public GraphQL endpoints by security researchers found that roughly 30% of tested schemas exposed at least one field with broken object-level authorization, closely mirroring the OWASP API1:2023 category (Broken Object Level Authorization).
Can attackers still perform SQL or NoSQL injection through a GraphQL resolver?
Yes — GraphQL's type system validates the shape of input, not its content, so injection still happens at the resolver-to-datastore boundary exactly as it would in any other Node.js code. A resolver that takes a filter: String argument and interpolates it into a raw MongoDB $where clause or a template-literal SQL string is exploitable the same way an unparameterized Express route handler is (CWE-89 for SQL, CWE-943 for NoSQL). The GraphQL schema will happily validate filter: "'; DROP TABLE users; --" as a valid string and hand it to your resolver unchanged. Use parameterized queries or an ORM that parameterizes for you — Sequelize's replacements/bind options, Prisma's generated query builder, or Mongoose with mongo-sanitize applied to any object passed into $where or $regex operators. Never build query strings by concatenating resolver arguments, and treat every scalar argument in your schema as untrusted input regardless of the GraphQL type declared for it.
Should introspection and the GraphQL Playground stay enabled in production?
No — introspection should be disabled in any environment reachable by untrusted clients, because it hands an attacker your entire schema, including internal-only mutations and admin fields you never documented publicly. Apollo Server has disabled introspection automatically when NODE_ENV=production since Apollo Server 2, and Apollo Server 4 (released November 2022) goes further by disabling the built-in landing page and Playground UI outside of a configured allowlist. The problem is that many teams override this default to keep internal tooling working, then forget to gate that override behind authentication. If your team needs introspection for internal GraphiQL access, put it behind the same session-based auth that protects your admin panel, not behind an easily-guessed feature flag or an IP allowlist that breaks the first time someone works from a coffee shop.
What is a GraphQL CSRF attack, and why did Apollo make prevention mandatory in 2022?
It's a cross-site request forgery executed through a GET request with a non-preflighted content type, letting an attacker's page trigger mutations using a logged-in victim's session cookies without the browser's CORS check ever firing. Because many GraphQL servers accept queries via GET with content-type: text/plain (to support caching and simple clients), a malicious page can submit a cross-origin form that the browser treats as "simple" and sends without a CORS preflight — bypassing the origin check entirely. This was documented publicly enough that Apollo shipped ApolloServerPluginCSRFPrevention as an on-by-default plugin in Apollo Server 4, which requires a custom header or a non-simple content type on every request. If you're still running Apollo Server 3 or a bare express-graphql/graphql-http setup, add this check manually: reject any GraphQL request that lacks a header like apollo-require-preflight or x-requested-with.
Which Node.js GraphQL dependencies most often introduce known vulnerabilities?
The packages sitting directly in the request-parsing path — graphql, apollo-server and its sub-packages, and file-upload middleware like graphql-upload — because they process untrusted input before any application code runs. graphql-upload has had multiple denial-of-service advisories tied to unbounded multipart parsing in versions prior to 13.0.0, and apollo-server-core carried the pre-4.0 CSRF exposure described above for every app that hadn't manually patched it. Run npm audit or a dedicated SCA scanner against package-lock.json on every PR, not just on a quarterly cadence — the median time between a GraphQL-ecosystem CVE disclosure and public exploit code appearing on GitHub is measured in single-digit days, not months. Pin graphql to a version with active security support (the 16.x line, as of this writing) and treat any transitive dependency of apollo-server the same way you'd treat a direct one, since resolver code executes with the same privileges as the framework code around it.
How Safeguard Helps
Safeguard's reachability analysis traces whether a vulnerable function in graphql, apollo-server, or graphql-upload is actually called by one of your resolvers, so your team isn't triaging every CVE in the dependency tree — only the ones a request can actually reach. Griffin AI reads your resolver and schema code directly to flag missing field-level authorization checks, unparameterized queries reaching a database driver, and introspection left enabled outside test environments, the exact patterns covered above. Safeguard generates and ingests SBOMs for your Node.js services automatically on every build, giving you a continuously current inventory of every GraphQL-adjacent package and its version instead of a snapshot from the last manual audit. When a fix is available — a patched graphql-upload release, a corrected resolver pattern — Safeguard opens an auto-fix pull request with the diff pre-applied, so remediation is a review-and-merge instead of a multi-day ticket.