This post is part of a series on trappsec — an experimental open-source deception framework designed to help developers detect attackers who probe API business logic. The core idea is simple: build realistic traps inside your application so that targeted reconnaissance can be correlated to a real user identity.

When discussing the trappsec philosophy, I often say that good traps must blend in with your business logic. They should look like an ordinary, even boring, part of your API. If something looks unusual or overly sensitive, experienced attackers will avoid it.

At the same time, there are opportunities to design traps that are specific to certain frameworks or technologies, while still preserving this principle. One such opportunity is a fake GraphQL interface — especially in an application that does not use GraphQL at all.

Why does GraphQL work well as a trap? GraphQL supports schema introspection via a standardized query interface. If enabled, this allows a client to retrieve the full schema definition, including types and operations. Because introspection can expose significant structural information, automated tooling frequently checks for accessible GraphQL endpoints such as /graphql.

Even applications that do not implement GraphQL might receive these probes. That makes such endpoints useful for detecting automated reconnaissance without requiring additional lure mechanisms.

None
a relatively simple introspection query in graphQL.

Building the trap In this post, we will implement a realistic but simple GraphQL decoy in a Python Flask application using trappsec. The goal is to detect authenticated users performing deep reconnaissance before they find a real vulnerability in your application.

We will highlight only the key parts. The full example code can be found inside the examples folder of the trappsec github repository.

The first step is to initialize trappsec Sentry and configure identity extraction. This is important because we want reconnaissance attempts to be tied to an authenticated account, not just an IP address.

# main.py
from flask import Flask, request, jsonify, g
import trappsec

app = Flask(__name__)

# initialize trappsec
ts = trappsec.Sentry(app, 
  service="SomeService", environment="Development")

# Tell trappsec how to identify the attacker 
# by linking the request to your internal user session 
ts.identify_user(lambda r: {
    "user": g.user_id,
    "role": "user"
})

@app.before_request
def mock_auth():
    # In a real app, this would be handled by your session/JWT logic
    g.user_id = "user_12345"

Next, we define the decoy route. This route will behave like a standard /graphql endpoint but is entirely managed by the trappsec engine.

# main.py - continued
from custom import graphql_trap

# Declare the trap:
# This interceptor catches POST requests to /graphql.
# If the user is unauthenticated, it returns a 401; 
# otherwise, it executes our custom trap logic.
ts.trap("/graphql") \
    .methods("POST") \
    .intent("GraphQL Reconnaissance") \
    .respond(200, graphql_trap) \
    .if_unauthenticated(
        401,
        {
            "errors": [
                {"message": "Unauthorized"}
            ]
        }
    )

Now, we design a custom responder that behaves like a real GraphQL endpoint. Instead of returning static JSON, we parse the query, validate it against a dummy schema, block introspection and return proper error structures.

# custom.py
from graphql import parse, validate, build_schema, GraphQLError, specified_rules
from graphql.validation import NoSchemaIntrospectionCustomRule

from flask import request

# We define a minimal, empty schema to validate against
dummy_schema = build_schema("""type Query { name: String }""")

# We include the NoSchemaIntrospection rule to mimic a 
# hardened API that has disabled introspection.
validation_rules = specified_rules + (NoSchemaIntrospectionCustomRule,)

def graphql_trap(req):
    """
    Handles the incoming decoy request. It validates the syntax 
    but prevents introspection, making the attacker think they 
    found a real, but restricted, endpoint.
    """
    body = request.get_json(silent=True) or {}
    query = body.get("query", "")

    try:
        ast = parse(query)
    except GraphQLError as e:
        return {"data": None, "errors": [e.formatted]}

    errors = validate(dummy_schema, ast, rules=validation_rules)
    if errors:
        return {"data": None, "errors": [errors[0].formatted]}

    return {"data": None}

Unauthenticated requests fail auth — as expected.

# the probe sent by the attacker
# does not have a jwt token attached
curl -X POST http://127.0.0.1:8000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ __schema { types { name } } }"}'

# the response seen by the attacker
{
  "errors": [
    { "message": "Unauthorized" }
  ]
}

Syntax or structural errors return properly formatted errors.

# the probe sent by the attacker
# has a missing { between __schema and types
curl -X POST http://127.0.0.1:8000/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <TOKEN>" \
  -d '{"query":"{ __schema types { name } } }"}'

# the response seen by the attacker
{
  "data": null,
  "errors": [
    {
      "message": "Syntax Error: Unexpected '}'.",
      "locations": [
        {
          "line": 1,
          "column": 29
        }
      ]
    }
  ]
}

Introspection attempts return the same validation error you would expect from a production server with introspection disabled.

# the probe sent by the attacker
curl -X POST http://127.0.0.1:8000/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <TOKEN>" \
  -d '{"query":"{ __schema { types { name } } }"}'

# the response seen by the attacker
{
  "data": null,
  "errors": [
    {
      "message": "GraphQL introspection has been disabled, but the requested query contained the field '__schema'.",
      "locations": [
        {
          "line": 1,
          "column": 3
        }
      ]
    }
  ]
}

All other valid queries simply return a null response as per the convention adopted in this example. If required, more complex deception and responses are possible though they are not always necessary.

# the probe sent by the attacker
curl -X POST http://127.0.0.1:8000/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <TOKEN>" \
  -d '{"query":"mutation { updateUserRole(id: 1, role: \"admin\") { success error } }"}'

# the response seen by the attacker
{"data": null}

If the trap returned generic JSON or HTTP errors, a moderately experienced attacker could quickly identify it as fake. By using the GraphQL parser and validation pipeline, the responses follow the exact structure defined by the GraphQL specification, giving no reason to the attacker to suspect deception. Meanwhile, the monitoring team gets notified with context that allows them to take action effectively and decisively.

// alert sample for authenticated probes. 
// unauthenticated probes are logged as signals
{
 "timestamp": 1770922295.7872922,
 "event": "trappsec.trap_hit",
 "type": "alert",
 "path": "/graphql",
 "method": "POST",
 "user_agent": "curl/8.16.0",
 "ip": "127.0.0.1",
 "intent": "GraphQL Reconnaissance",
 "user": "alice",
 "role": "user",
 "app": {
 "service": "SomeService",
 "environment": "Development",
 "hostname": "ftfy-one"
 }
}

Closing note

This technique leverages the attacker's own thoroughness against them. The more avenues the attacker tries to find to map an application, the more opportunities we get to discover them doing so. By emulating just enough of the protocol, the attacker cannot differentiate between a real GraphQL endpoint and the trap. With trappsec's auth-aware response primitives, you can ignore the noise from random scanners and focus on alerts from authenticated attacker probing your application.