GRAPHQL, SWAGGER, GRPC — YOUR API DOCS ARE AN ATTACK SURFACE
Three different ways your API tells an attacker exactly how to talk to it. GraphQL introspection. Swagger UI shipped to production with the bearer token prefilled from your last test. gRPC reflection. All of them on by default in AI-codegen-shaped stacks.
The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate.
Three doors marked “documentation” that don’t lock
Most of an attacker’s job, on a target they haven’t seen before, is figuring out the API surface. Endpoints, parameters, types, auth, how it all fits together. The natural-language docs help. The schema itself helps more — and APIs increasingly ship their schema as part of the runtime.
GraphQL with introspection on. Swagger UI on /api/docs/. gRPC with reflection enabled. These are productivity features for developers. They are also, when shipped to production, a complete tour of the API surface delivered to whoever asks first.
We find them on by default in roughly half the AI-codegen-shaped APIs we scan.
GraphQL introspection
{ __schema { types { name fields { name type { name } } } } }
That query, against a GraphQL endpoint with introspection on, returns the entire schema. Every type, every field, every argument. We see this enabled by default on AI-generated Apollo Server setups — the “production-ready” recipe in the docs has introspection off, but the AI’s example always copies the dev recipe.
Live: https://gapbench.vibe-eval.com/site/graphql-api/. POST that introspection query and you get the full schema back.
Swagger UI in production
The bug: Swagger UI is a single-page app. Most AI-generated Express or NestJS scaffolds add it at /api/docs or /swagger. The dev pattern is:
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec))
That line, unguarded, ships a fully functional API explorer to every visitor. The worse variant: developers test endpoints in Swagger UI by pasting a bearer token. Swagger UI saves the token in localStorage for the user’s convenience. If the developer ever opens the docs from production while logged in, the token is now in localStorage on production. Anyone who lands on /api/docs after that has a working session.
Live: https://gapbench.vibe-eval.com/site/swagger-exposed/.
gRPC reflection
Less common in pure web stacks but increasingly relevant as AI generators help people build microservices. With reflection enabled, a single grpc_cli ls lists every service. From there it’s a short step to enumerating every method and probing them.
Live: https://gapbench.vibe-eval.com/site/grpc-reflection/.
Why AI codegen leaves these on
Same root cause for all three: dev convenience features that have a production switch the AI doesn’t flip. The model sees thousands of tutorials enabling these features in dev. It sees fewer tutorials specifically saying “now disable them in production.” It writes the dev config and leaves it.
A second cause for the Swagger variant specifically: framework defaults. NestJS’s @nestjs/swagger module, by default, exposes the docs at /api. Express middleware setups frequently follow the same pattern. The flag to disable is one line. The default is to enable.
A specific incident — Swagger UI with the bearer prefilled
Anonymized. A B2B product had its API documentation at /api/docs/ powered by Swagger UI. The documentation was deployed to production — partly intentional (“our customers want to see the API”), partly inertia (“we never thought to remove it”).
The team had been using Swagger UI for internal testing. A senior engineer had pasted his bearer token into the Swagger UI’s “Authorize” dialog while debugging a customer issue. Swagger UI saved the token in localStorage for convenience.
Anyone who visited /api/docs/ from the same browser the engineer had used got a Swagger UI with his token pre-loaded. From there, every “Try it out” button on every endpoint sent his token. His role had admin permissions across all customer accounts.
Discovery happened when one of the engineers, looking at the panel from a different machine, noticed the token was still there. The team realized anyone with access to the panel from any browser the engineer had visited would have the token. They couldn’t audit who had visited; they couldn’t audit which actions had been taken. They rotated the token and added a strict policy: Swagger UI off in production, period.
The deeper bug here isn’t really Swagger UI — it’s the workflow. Engineers paste tokens into UIs. UIs cache them. Cached tokens leak via shared browsers, screen captures, browser export. The discipline is “tokens never go into UI input fields.” Use API testers (Postman, Insomnia, Bruno) that scope tokens to the project, or use environment-variable-based testing.
What proper API discovery should look like
If you want public API documentation:
- Generate static docs at build time. Markdown, HTML — checked into the repo, served from
/docs/. No live testing UI. - OpenAPI spec available for download.
/api/openapi.jsonor similar. Customers who want to test can import into their own tooling. - Live testing UI behind authentication only. If you want a “try it out” UI, it lives at
/internal/api-explorerbehind your team’s auth.
The pattern that fails: live testing UI public, with caching, with the team using it as a dev tool. The combination is the bug.
GraphQL beyond introspection
Even with introspection off, GraphQL has subtle exposure surfaces:
- Field-level errors leak schema. Submitting a query with a typo produces “Did you mean…?” errors that reveal valid field names. AI-generated GraphQL servers leave this enabled by default.
- Persisted queries vs ad-hoc queries. Some servers can be configured to only accept allowlisted query hashes. Most aren’t.
- Depth and complexity limits missing. Without limits, an attacker can submit a deeply-nested query that explodes the cost. We’ve seen
(a { b { c { ... }}})patterns hit 30+ levels of nesting. - Batch query DoS. GraphQL allows multiple queries per request. Without per-request limits, an attacker can batch 1,000 queries and amplify load.
The defenses (Apollo Server has all of them; Yoga has most):
introspection: falseformatErrorto strip “Did you mean” hintsvalidationRuleswith depth and complexity limits- Query allowlist for production
- Per-request batch limits
Wrong fix vs right fix — GraphQL
// WRONG: introspection only
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: false,
})
// Missed: validation rules, depth limits, batch limits, error message hardening
// RIGHT: hardened GraphQL setup
import depthLimit from 'graphql-depth-limit'
import { createComplexityLimitRule } from 'graphql-validation-complexity'
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: false,
validationRules: [
depthLimit(7),
createComplexityLimitRule(1000),
],
formatError: (err) => {
// Strip schema hints from error messages
const safe = err.message.replace(/Did you mean.*?\?/g, '')
return { message: safe }
},
})
Cross-stack notes
- GraphQL servers (Apollo, Yoga, Mercurius): All have introspection toggles. Default-on in dev, default-off in production for some, configurable for others.
- gRPC (gRPC-Go, gRPC-Java):
reflection.Register()— call this in dev only. - Swagger UI variants: Swagger UI, Redoc, Stoplight Elements. Each has different production-readiness defaults. Audit.
How we detect it
GraphQL: send the introspection query. If it returns a schema, that’s the finding. If it returns an error specific to introspection-disabled, the bug is fixed.
Swagger: hit common docs paths — /swagger, /api/docs, /docs, /api-docs, /redoc. Any 200 with a recognizable Swagger UI shell is a finding. We additionally look for prefilled tokens in the served HTML.
gRPC: probe for the reflection service on the gRPC port. Standard gRPC clients can do this in a single call.
All three are runtime detections — they require a live HTTP/gRPC endpoint, not just code. Static scanners can flag the enabling config in source if they know to look, but they miss every case where the config is set via env var, command-line flag, or a parameterized framework default.
Fix
GraphQL: disable introspection in production. Apollo Server has introspection: false. GraphQL Yoga has disableIntrospection. Hasura has HASURA_GRAPHQL_ENABLE_CONSOLE=false. The flag is named consistently across libraries. Set it.
Swagger: don’t ship the docs to production at all. Move the docs route behind an environment check or behind authentication. If you want public docs, generate them at build time and serve them as static HTML on a separate path that doesn’t include the live tester.
gRPC: don’t register the reflection service in production. The standard gRPC servers register it conditionally — make sure your conditional is “dev only,” not always.
For all three: rotate any credentials that may have been used in a tester UI. If Swagger UI was deployed with prefilled tokens, those tokens are leaked.
CWE / OWASP
- CWE-200 — Information Exposure
- CWE-540 — Inclusion of Sensitive Information in Source Code
- CWE-538 — File and Directory Information Exposure
- OWASP API Top 10 — API9:2023 Improper Inventory Management
- OWASP Top 10 — A05:2021 Security Misconfiguration
Reproduce it yourself
- GraphQL introspection: https://gapbench.vibe-eval.com/site/graphql-api/
- Swagger UI exposed: https://gapbench.vibe-eval.com/site/swagger-exposed/
- gRPC reflection: https://gapbench.vibe-eval.com/site/grpc-reflection/
Related reading
COMMON QUESTIONS
TEST YOUR API SURFACES
We probe for GraphQL introspection, Swagger UIs, and gRPC reflection — and report what they leak.