API Security, Authentication and Authorization Questions
Controlling who can call an API, what they may do, and defending it against abuse. Covers the access-control mechanics: API keys, OAuth 2.0 flows, OpenID Connect, JWT issuance/validation, session vs. token auth, scopes/roles for fine-grained authorization, token lifetime and refresh, mutual TLS, and machine-to-machine vs. user-delegated access. Also covers the adversarial hardening view: input validation, injection and deserialization risks, broken object-level authorization (BOLA), mass assignment, secrets handling, and the OWASP API Security Top 10, plus securing data in transit, preventing enumeration/scraping, and testing APIs for vulnerabilities.
Perform a threat model for an internal REST API that returns user profiles containing PII (name, email, phone, SSN). Identify actors, assets, trust boundaries, and top threats (data exfiltration, broken access controls, excessive data exposure). Prioritize mitigations (authentication, authorization, field-level encryption, logging, least privilege) and propose a remediation roadmap.
Sample Answer
Direct answer
Start by naming who can actually reach this API and from where (the trust boundary), because "internal" is often an assumption rather than a fact. Then rank the threats by how directly they expose the Social Security Number (SSN), the single highest-sensitivity field here, and mitigate in the order that closes the biggest exposure fastest: access control first, then field-level protection, then the logging that lets you detect and prove what happened.
Structured elaboration
Actors, assets, and trust boundaries
- Actors: legitimate internal callers (other services, employee-facing internal tools); a compromised internal credential or service (insider misuse, or an attacker who moved laterally after breaching something else); an external attacker who reaches this "internal" endpoint anyway, through a VPN compromise, a server-side request forgery pivot, or a cloud network misconfiguration that exposes it publicly.
- Assets: the Personally Identifiable Information (PII) fields themselves, with the SSN as the highest-value target; the credentials that unlock the API; and the audit trail itself, which is an asset in its own right, since a tampered or missing log destroys your ability to detect or later prove what happened.
- Trust boundaries: caller to API (is "internal-only" actually enforced at the network layer, or just assumed), API to data store (does the API connect with a narrowly scoped read role or an overprivileged one), and API to the logging/observability pipeline (do the logs themselves become a second copy of the PII sitting in a less-protected system).
Top threats, mapped to the OWASP API Security Top 10:2023 (Open Web Application Security Project's current API-specific list)
- Broken access controls: a caller can request another user's record by changing an ID (API1:2023 Broken Object Level Authorization), or a caller without admin privileges can reach a bulk-export or admin-only route (API5:2023 Broken Function Level Authorization).
- Data exfiltration: no cap on page size or query volume lets one call retrieve far more records than any legitimate use case needs (API4:2023 Unrestricted Resource Consumption).
- Excessive data exposure: the endpoint returns the full profile object, SSN included, even to callers whose actual use case only needed a name and email (API3:2023 Broken Object Property Level Authorization), so any downstream leak of a normal, "authorized" response still contains the SSN.
Mitigations, prioritized by exposure closed per unit of effort
- Authentication: every caller presents a verifiable identity, no anonymous internal calls.
- Authorization, at two levels: object-level (does this caller own or have a legitimate reason to access this specific record) and property-level (does this caller's role need the SSN field at all, or only name and email).
- Least privilege: the API's own database credential should not be able to read columns it never needs, and individual caller roles should be scoped narrowly, so a support-tool integration gets name and email, never SSN.
- Field-level encryption: encrypt the SSN at rest with a key managed by a separate service, decrypted only inside the narrow code path that is actually authorized to see it, so a raw database dump or an unscoped query does not yield a plaintext SSN.
- Logging: audit access to the SSN field specifically (who viewed which record's SSN and when), and make sure the logging pipeline itself never records the SSN in cleartext, or you have just built a second unprotected copy of the same asset.
Worked example
A marketing internal tool calls GET /users/{id} to fetch a display name for personalization, but the endpoint returns the entire user object, SSN included, because nobody scoped the response by caller role. An engineer debugging the tool copies the raw JSON response into a shared internal channel to ask for help. With property-level authorization in place, that same call would have returned only {name, email}, so the copy-pasted response could never have contained the SSN in the first place, even though the original access was by a legitimate, authenticated caller doing a legitimate task. This is the case for prioritizing property-level authorization highly: it protects the data even when every other control behaved correctly and the access itself was "allowed."
Remediation roadmap
- Weeks 1 to 2 (quick wins): enforce authentication on every path, add object-level ownership checks, cap page size, closing the largest exfiltration exposure fastest.
- Month 1: add property-level response scoping per caller role so most callers stop receiving the SSN field entirely, and turn on SSN-access audit logging.
- Quarter: implement field-level encryption for the SSN with a dedicated decrypt path, formally review and tighten the API's database role to least privilege, and set up a recurring access review of exactly which callers and integrations can reach this endpoint.
Trade-offs & pitfalls
Restricting response fields can break internal tools that grew a habit of over-fetching; plan a deprecation window rather than a hard cutover that breaks something in production on day one. Field-level encryption adds real latency and operational complexity, so target it at the SSN and comparable regulator-sensitive fields specifically rather than encrypting every column by default. Without the audit logging piece, the rest of this roadmap is unverifiable after the fact: treat logging as a control this design depends on, not an afterthought bolted on for debugging.
Explain Cross-Origin Resource Sharing (CORS): the headers involved, the browser enforcement model, and what security guarantees CORS does and doesn't actually provide. Then walk through why a wildcard Access-Control-Allow-Origin combined with Access-Control-Allow-Credentials: true is a dangerous configuration, and how you'd safely configure CORS on an API that uses cookies or bearer tokens.
Sample Answer
Direct answer
Cross-Origin Resource Sharing (CORS) is a browser-enforced relaxation of the same-origin policy that lets a web page on one origin ask a server on another origin to opt in to being called from JavaScript. It is enforced entirely client-side by the browser, so it protects browser-based callers from a malicious page, but it does nothing to stop a non-browser caller, curl, another server, a script, since none of those ever consult CORS headers at all.
Structured elaboration
Headers involved
- Request side:
Origin, the browser telling the server where the request came from. - Response side:
Access-Control-Allow-Origin(which origin or origins may read the response),Access-Control-Allow-Credentials(whether cookies or HTTP auth may be included), andAccess-Control-Allow-Methods/Access-Control-Allow-Headers(what a preflight check actually permits). - For non-simple requests, custom headers, methods like
PUTorDELETE, certain content types, the browser first sends anOPTIONSpreflight request and only proceeds with the real request if the preflight response allows it.
Browser enforcement model
This is the most commonly misunderstood part: for many requests, the server still processes and can still respond to a cross-origin call even when the origin isn't allowed. What CORS actually blocks is the browser handing that response back to the calling page's JavaScript. CORS is a response-reading gate enforced by browsers, not a request-blocking firewall, and it provides zero protection against server-to-server calls or any tool that simply does not implement browser CORS rules.
What CORS does and doesn't guarantee
- Does: stop a malicious website from using a logged-in victim's browser and its ambient cookies to read data back cross-origin via JavaScript, when configured correctly.
- Doesn't: authenticate or authorize anyone, protect an API from direct non-browser calls, or substitute for real server-side access control.
Why the wildcard-plus-credentials combination is dangerous
Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true is explicitly disallowed by the CORS specification itself, browsers refuse to honor that exact literal combination. But a very common misconfiguration achieves the same dangerous effect without triggering that block: reflecting whatever Origin header the request sent back as the allowed origin, for every single request, while also setting Access-Control-Allow-Credentials: true. That passes spec validation (it is technically not a literal wildcard) but functionally means any website in the world can make a credentialed, cookie-carrying request to this API and read the response, which lets a malicious site silently ride a logged-in user's session and exfiltrate their data.
Safe configuration for cookie or bearer-token APIs
Maintain an explicit allowlist of known, trusted origins, your own frontend domains and named partner domains, and validate the incoming Origin header against that list, only echoing it back, never a wildcard, when it actually matches. Set Access-Control-Allow-Credentials: true only on that narrow, allowlist-matched response, never combined with a reflect-anything policy. For bearer-token APIs that do not rely on cookies at all, sending the token in an Authorization header instead, you can often skip credentialed CORS entirely, since a stolen or reflected CORS configuration cannot ambiently attach a header a malicious page does not know to send.
Worked example
Dangerous configuration: a request arrives with Origin: https://evil.example, and the server responds with Access-Control-Allow-Origin: https://evil.example plus Access-Control-Allow-Credentials: true, for every incoming origin, with no allowlist check at all.
Safe configuration: the server checks the incoming Origin against an explicit list, ["https://app.example.com", "https://partner.example.com"], echoes it back only on a match, and either returns a 4xx or simply omits the CORS headers (which the browser then treats as a block) when there is no match.
Trade-offs & pitfalls
"Just reflect the Origin header, it's easier than maintaining an allowlist" is precisely the dangerous shortcut described above. CORS misconfiguration is a browser-side control, so testing it with curl or Postman will not reveal the vulnerability the way it actually manifests in the real world; you have to test from an actual disallowed origin inside a browser, or reason directly about the response headers, since curl never enforced CORS in the first place and a working curl test creates a false sense of security.
Walk me through how you'd build a scalable pipeline to detect API abuse (credential stuffing, scraping, fraud) across hundreds of services and millions of requests per minute. Include data collection and enrichment (geo, ASN, device fingerprint), real-time detection and scoring (streaming feature aggregation, ML models), alerting to SIEM/SOAR, automated blocking/lists and the feedback loop for model updates, while preserving low latency on request paths.
Sample Answer
Direct answer
At the scale described (hundreds of services, millions of requests per minute) the pipeline has
to split into a fast synchronous path that adds only a few milliseconds per request, and a
slower asynchronous path that does the expensive enrichment and model scoring off to the side
and feeds its verdicts back as a cache the fast path can check cheaply. You cannot run full
machine-learning scoring inline on every request at that volume without blowing the latency
budget, so the design's central decision is what stays synchronous (a cheap reputation lookup)
versus what happens asynchronously and only changes future requests (enrichment, scoring, model
updates).
Structured elaboration
Sizing the problem first. "Millions of requests per minute" is on the order of tens of
thousands of requests per second; for example 5,000,000 requests/minute is about 83,000
requests/second sustained. Any synchronous, per-request check has to fit inside a latency budget
of a few milliseconds at that rate, which rules out anything that calls out to a heavyweight
model or an external enrichment API inline.
Data collection and enrichment. Capture request metadata at the edge (source IP, user agent,
TLS fingerprint, authenticated identity if any) and enrich it: geolocation and ASN (autonomous
system number, which identifies the network/ISP a request came from) lookups from a local,
periodically-refreshed dataset rather than a live network call; device fingerprinting from
client-side signals where available. Do the enrichment lookups against an in-memory or
local-cache copy of the reference data, not a network round trip per request, since a network
call per request at 83,000 requests/second is its own outage risk.
Real-time detection and scoring. Split scoring into two tiers:
- A cheap, synchronous tier that checks a precomputed verdict (IP or identity already on a
blocklist or flagged as high-risk from a shared, low-latency cache) and applies simple rules
(velocity thresholds) that need no model inference. - An asynchronous tier that aggregates streaming features (request velocity per identity, ratio
of failed to successful auth attempts, geographic dispersion of a single credential's usage)
over sliding windows, and periodically runs those aggregated features through a scoring model.
The model's output updates the shared verdict cache that the synchronous tier reads, so the
request path never blocks on model inference; it only ever blocks on a cache read.
Alerting to SIEM/SOAR. Route confirmed and borderline detections to the security team's SIEM
(security information and event management system, which centralizes security logs for
investigation) and SOAR (security orchestration, automation and response, which can trigger
automated response playbooks) rather than only auto-blocking. High-confidence, high-severity
patterns can trigger automated blocking directly; medium-confidence patterns should raise an
alert for a human or a scripted playbook to act on, since automated blocking on a weak signal
risks blocking real users (a false positive that costs revenue and trust).
Automated blocking and the feedback loop. Blocking decisions (IP bans, credential lockouts,
CAPTCHA challenges) need to be revisable: log every automated action with the signal that caused
it, and feed confirmed false positives (a legitimate user who got blocked and later proved it,
for example by successfully completing account recovery) back into the model's training data or
into rule exceptions, so the system's precision improves rather than accumulating permanent
mistakes.
Preserving low latency on request paths. The single most important architectural rule is
that nothing on the synchronous request path may depend on the availability or latency of the
detection pipeline's slower components. If the verdict cache is unreachable, the request path
should fail open to "no additional friction" (log the miss, do not block), not fail closed to
"block everything," unless the product's risk tolerance explicitly demands the opposite for a
specific high-value action (initiating a payment, for example).
Worked example
graph LR
R[API Request] --> E[Enrichment: geo, ASN, device fingerprint]
E --> F[Streaming Feature Aggregation]
F --> ML[Real time ML Scoring]
ML -->|high risk| B[Auto Block or Challenge]
ML -->|medium risk| SIEM[Alert to SIEM and SOAR]
ML -->|low risk| P[Pass Through]
B --> FB[Feedback Loop]
SIEM --> FB
FB --> ML
Reading the diagram left to right: the synchronous request path is only the leftmost box, since
everything from "Streaming Feature Aggregation" onward runs asynchronously against buffered
data, not inline with the request. At 83,000 requests/second, if the synchronous enrichment plus
a verdict-cache read together cost 2ms, that is a fully absorbable addition to a typical API's
latency budget; if the same path instead waited on the "Real time ML Scoring" box per request,
the pipeline would need that box to sustain 83,000 scoring calls per second with sub-millisecond
latency each, which is why that box is drawn as feeding a cache asynchronously rather than
sitting inline.
Trade-offs and pitfalls
- Fail-open vs. fail-closed under detector outage. Failing open (letting requests through
when the detection pipeline is down) protects availability and revenue but temporarily loses
abuse protection; failing closed protects against abuse but can turn a detection-pipeline
outage into a full product outage. State this trade-off explicitly per endpoint rather than
picking one default for the whole system, since a login endpoint and a public read-only search
endpoint have very different risk profiles. - False positives have a real cost, not just a technical one. An overly aggressive
auto-block tier degrades the experience for legitimate users and generates support load; this
is why the design routes medium-confidence signals to an alert instead of an automatic block,
and why the feedback loop exists at all. - Common wrong turn: scoring every request synchronously "to be safe." This looks more
thorough on paper but does not scale to the stated volume and turns the fraud pipeline into
the system's latency bottleneck; the asynchronous, cache-backed design is not a shortcut, it is
the only version of this architecture that survives the request rate. - Common wrong turn: treating the feedback loop as optional. Without it, the model's
precision decays as attackers adapt and as the false-positive rate silently rises, since
nothing in the system is measuring or correcting for it.
Build automated tests to detect field level authorization bypass in a GraphQL service where some schema fields should only be visible to certain roles. Provide a practical test script example (for instance in Python or JavaScript) that: 1) enumerates accessible fields for an admin user, 2) repeats the same queries for a low privilege user, and 3) asserts unauthorized fields are absent or redacted. Explain how to handle schema introspection differences between environments.
Sample Answer
Direct answer
Test field-level authorization the same way you would test any access-control rule: run the
identical query as two different callers (a caller who should see a protected field and one who
should not) and assert on the difference. A GraphQL server that enforces field rules only in the
resolver, and not also in the schema's introspection output, will happily tell an unauthorized
caller a sensitive field exists even while refusing to return its value, so the test also needs
to work when introspection is turned off in an environment.
Structured elaboration
Why this is a distinct risk from REST-style object-level authorization. In a REST API you
typically authorize per endpoint or per object id. In GraphQL, a single query can request many
fields on an object in one call, and each field can have its own authorization rule (this is the
GraphQL-specific instance of what OWASP (the Open Web Application Security Project, a nonprofit that publishes ranked lists of common API and web vulnerability categories) calls, in its 2023 API Security Top 10, Broken Object Property
Level Authorization, API3:2023). It is easy to correctly gate the object as a whole (only the
owner can query user(id)) while forgetting that one of its fields, say ssn or
internalRiskScore, should be admin-only regardless of who owns the parent object.
Building the test:
- Enumerate the fields to check. In an environment where introspection is enabled, query
the schema itself to get the full field list for the type under test. In production-like
environments introspection is usually disabled as a hardening step, so the test needs a
fallback: a checked-in SDL (schema definition language) snapshot of the type, kept current by
a CI check that diffs it against the schema whenever the schema changes. Do not let "no
introspection here" become "no test coverage here." - Run the query as the high-privilege caller and record which fields came back populated.
This is your baseline of what the type is supposed to expose to someone with full access. - Run the identical query shape as the low-privilege caller.
- Assert on the difference, not just on errors. Many GraphQL servers do not reject the
whole request when a caller cannot read one field; they returnnullfor that field plus an
entry in the response'serrorsarray (a "partial success"). A test that only checks the top
level HTTP status code, or only checks for the presence of an error, will miss a server that
silently returns the real value instead ofnull. Assert directly that every field which
should be role-gated is either absent,null, or explicitly redacted in the low-privilege
response, and separately assert that fields which should be visible did not regress.
Worked example
# Field-level authorization test for a GraphQL API.
#
# In real CI this would POST to a live endpoint (e.g. with `requests` or `gql`),
# sending the query with an admin token, then again with a low-privilege token.
# To keep this example runnable with no network dependency, the "server" below
# is a tiny in-process resolver enforcing the same rule a real GraphQL server
# would via a per-field `@auth(role: ...)` directive: it nulls out any field
# the caller's role does not satisfy. The test logic (enumerate -> re-run as
# low-priv -> assert unauthorized fields absent) is exactly what you would run
# against a live endpoint.
FIELD_ROLES = {
"id": None,
"email": None,
"fullName": None,
"ssn": "ADMIN",
"internalRiskScore": "ADMIN",
"billingAddress": "ADMIN",
}
USER_RECORD = {
"id": "u-42",
"email": "jane@example.com",
"fullName": "Jane Doe",
"ssn": "123-45-6789",
"internalRiskScore": 87,
"billingAddress": "1 Market St",
}
def execute_query(fields, role):
# Returns the same shape a real GraphQL response would: unauthorized
# fields come back null with an entry in `errors`, matching how most
# GraphQL servers signal a partial-authorization failure.
data, errors = {}, []
for f in fields:
required = FIELD_ROLES[f]
if required is None or required == role:
data[f] = USER_RECORD[f]
else:
data[f] = None
errors.append(f"not authorized to read field '{f}'")
return {"data": data, "errors": errors}
def schema_fields_for_type(type_name, introspection_enabled=True):
# Falls back to a checked-in SDL snapshot when introspection is disabled
# (a common prod hardening step), instead of depending on a live
# introspection query that only works in dev/staging.
return list(FIELD_ROLES.keys())
def test_field_level_authorization_bypass():
all_fields = schema_fields_for_type("User", introspection_enabled=True)
# 1) enumerate what an admin can see
admin_response = execute_query(all_fields, role="ADMIN")
admin_visible = {f for f, v in admin_response["data"].items() if v is not None}
assert admin_visible == set(all_fields)
# 2) repeat the identical query shape for a low-privilege caller
user_response = execute_query(all_fields, role="USER")
user_visible = {f for f, v in user_response["data"].items() if v is not None}
# 3) assert every admin-only field is absent (redacted to null) for the low-priv caller
admin_only_fields = {f for f, req in FIELD_ROLES.items() if req == "ADMIN"}
leaked = admin_only_fields & user_visible
assert not leaked, f"low-privilege caller could read admin-only fields: {leaked}"
public_fields = set(all_fields) - admin_only_fields
assert public_fields <= user_visible
return {
"admin_visible": sorted(admin_visible),
"user_visible": sorted(user_visible),
"admin_only_fields_blocked_for_user": sorted(admin_only_fields),
"user_errors": user_response["errors"],
}
result = test_field_level_authorization_bypass()
print("admin_visible:", result["admin_visible"])
print("user_visible:", result["user_visible"])
print("admin_only_fields_blocked_for_user:", result["admin_only_fields_blocked_for_user"])
print("user_errors:", result["user_errors"])
print("PASS: no admin-only field leaked to the low-privilege caller")
Output:
admin_visible: ['billingAddress', 'email', 'fullName', 'id', 'internalRiskScore', 'ssn']
user_visible: ['email', 'fullName', 'id']
admin_only_fields_blocked_for_user: ['billingAddress', 'internalRiskScore', 'ssn']
user_errors: ["not authorized to read field 'ssn'", "not authorized to read field 'internalRiskScore'", "not authorized to read field 'billingAddress'"]
PASS: no admin-only field leaked to the low-privilege caller
The admin caller sees all 6 fields; the low-privilege caller sees only the 3 public fields, and
the 3 admin-only fields (ssn, internalRiskScore, billingAddress) come back null with a
matching error. A bug that forgot to gate one of those fields would flip that field from null
into user_visible, and the assertion on leaked would fail immediately, which is the exact
regression this test is designed to catch.
Trade-offs and pitfalls
Handling schema introspection differences between environments. Many teams disable
introspection in production for defense-in-depth, but leave it on in staging or dev. If your
test only discovers the field list by querying introspection live, it silently stops running in
the one environment where you most need it. Keep a versioned SDL snapshot as the source of truth
for "what fields exist," and add a separate, lightweight CI check that fails whenever a live
introspection query (in an environment where it is enabled) diverges from the snapshot, so the
snapshot itself cannot go stale.
Pitfall: testing only the object, not its fields. A team that already has a BOLA (Broken Object Level Authorization, OWASP API1:2023, a different category from the object-property-level one described earlier in this answer) style test
("user A cannot fetch user B's record") sometimes assumes field-level authorization is covered
by the same test. It is not: BOLA is about whose object you can reach, field-level
authorization is about which parts of an object you can see once you can reach it, and a
caller can legitimately own the object (their own profile) while still not being entitled to
every field on it (an internal risk score, say). Test the two separately.
Pitfall: asserting on HTTP status only. GraphQL servers conventionally return HTTP 200 even
for partially-authorized or partially-failed responses, with the real signal inside the response
body's data and errors. A test suite carried over from REST-style thinking that only checks
response.status_code == 200 will pass even when a field-level authorization bug leaks real
data, because the status code never changes.
Given a GraphQL mutation that accepts deeply nested input to create users and related resources, perform a threat model that focuses on injection, excessive data exposure, denial-of-service via complex nested queries, and authorization bypass. Propose precise mitigations such as sanitization, field-level authorization hooks, depth/complexity limiting, persisted queries and cost estimation.
Sample Answer
Direct answer
For a GraphQL mutation that accepts deeply nested input to create users and related resources,
the threat model has four connected risks: injection through any field that reaches a data
store or downstream system, excessive data exposure through the response shape a single query
can request, denial-of-service through the cost of resolving deeply nested or high-fan-out
selections, and authorization bypass at the object and field level within the nested structure,
not just at the mutation's top-level entry point. Because a single GraphQL request can touch
many resources and relationships in one call, each of these risks compounds with nesting depth
in a way a single flat REST endpoint does not.
Structured elaboration
Injection. Every leaf value in the nested input (a related resource's name, an address
field several levels deep) is still untrusted input reaching business logic and, eventually,
storage; the nesting does not change the injection risk, it just multiplies how many fields need
the same discipline applied consistently: parameterized queries, never string-built ones, plus
strict schema validation on every nested object (not only the mutation's top-level arguments,
which is where a reviewer's eye is naturally drawn) and correct output encoding wherever any of
this data is later rendered back out.
Excessive data exposure. Because the same mutation can request a return shape that includes
newly created and related resources, a response can overfetch: it can return internal or
sensitive fields on those related resources that the caller was never meant to see, simply
because the schema allows selecting them and nobody scoped the mutation's response shape as
carefully as its input shape was scoped. Mitigate this the same way field-level authorization
is enforced on queries: role- and ownership-aware field resolvers on the response type, not an
assumption that "this is a write endpoint, so read-side authorization doesn't apply here."
Denial-of-service via complex nested queries. A mutation with deeply nested input, or a
follow-up query selecting deeply nested relationships, can force the server to do
exponentially more work than the request's size on the wire suggests, since each level of
nesting can multiply the resolvers invoked by the requested page size at that level. Mitigate
with depth limiting (rejecting a query or mutation whose selection nests deeper than a configured
maximum) and query complexity/cost analysis (assigning each field a cost, multiplying by
requested list sizes at each level, and rejecting a request whose total cost exceeds a budget
before executing any of it).
Authorization bypass. A nested mutation creating multiple related resources in one call needs
authorization checked at every object being created or referenced, not only at the top-level
"can this user call this mutation at all" gate; a caller authorized to create their own user
profile is not automatically authorized to attach that profile to an organization they do not
belong to, simply because the organization reference is buried three levels into the nested
input rather than being the mutation's top-level argument.
Concrete mitigations, tied to the risks above:
- Sanitization and parameterization at every leaf field, same discipline as any other
user-supplied input reaching a data store. - Field-level authorization hooks on both the input side (can this caller set this field or
reference this related object) and the response side (can this caller see this field on the
result), not only on the mutation as a whole. - Depth and complexity limiting, rejecting requests that exceed a configured nesting depth
or computed cost before execution begins, so a malicious request is rejected cheaply rather
than partially executed before being caught. - Persisted queries (the client sends a reference to a pre-registered, pre-approved query
shape rather than an arbitrary ad-hoc query string), which is a strong mitigation against
unexpected malicious query shapes specifically, since the server only ever executes shapes it
already reviewed and approved, though it does not by itself replace per-request authorization
checks on the data those approved shapes touch. - Cost estimation, the mechanism underlying complexity limiting: assign a numeric cost to
each field (higher for fields that fan out via a list) so the total cost of a specific request
can be computed and compared against a budget before execution.
Operational testing to validate the mitigations. These protections need to be verified the
same way any other security control is verified, with automated tests exercising the negative
cases: a test that submits a mutation nested one level past the configured depth limit and
asserts it is rejected before any database write occurs; a test computing a request's expected
cost against the cost model and asserting the server's rejection threshold matches; and a
field-level authorization test that runs the identical nested mutation as two different callers
(one entitled to set or see a given nested field or object, one not) and asserts the
unauthorized caller's attempt is rejected or the field comes back redacted, since a passing
depth-limit test says nothing about whether the authorization checks inside that depth are
actually being enforced.
Overfetching and introspection abuse, as related but distinct concerns. Overfetching (a
client requesting far more of the graph than its use case needs, even without malicious intent)
is a milder version of the excessive-data-exposure risk above and is best addressed by the same
field-level authorization plus reasonable default response shapes; introspection abuse (an
attacker using the schema's own introspection query to map out the entire graph, including
fields or types not intended for public discovery, as reconnaissance for a later attack) is
mitigated by disabling introspection in production, backed by a checked-in schema snapshot so
tooling and tests still have a field list to work from, or, where some introspection must stay
available, at minimum excluding internal-only types and fields from it.
Worked example
Consider a nested mutation: user { id, posts(first: 20) { id, comments(first: 20) { id } } }.
Using a simple cost model where a scalar field costs 1 point per row in scope and a list field
multiplies the cost of everything beneath it by the requested page size:
user.id : 1 (scope multiplier 1)
user.posts (node) : 1 (scope multiplier 1, before its own multiplier applies)
posts.id : 20 (scope multiplier 20, from posts' page size)
posts.comments (node) : 20 (scope multiplier 20)
comments.id : 400 (scope multiplier 20 * 20, nested page sizes compound)
-----------------------------------------------
total cost : 442
If the server enforces a cost budget of, say, 300 per request, this query is rejected before
execution, purely from its declared shape, with no database call made. Without complexity
limiting, a client (or attacker) could push first: 100 at each level instead of first: 20,
pushing the comments.id term alone to 100 * 100 = 10,000, a strictly wire-cheap request that
would force the server to resolve tens of thousands of rows.
Trade-offs and pitfalls
- Depth and complexity limits are blunt instruments that can reject legitimate, unusually
shaped requests, not just malicious ones; calibrate the budget against your real schema's
legitimate worst-case use cases, and expose a documented way for a legitimate client with a
genuinely larger need to request a higher limit, the same way an API rate-limit exception
process should exist. - Persisted queries strongly constrain query shape but do nothing about authorization on the
data a shape touches; a persisted query approved months ago can still be misused by a
caller who should not have access to the specific objects it happens to reference this time, so
persisted queries reduce, but do not replace, per-request field- and object-level authorization. - Common wrong turn: authorizing only the mutation's top-level entry point. A nested mutation
is not one flat authorization check, it is potentially many, one per object being created or
referenced within the nested input, and skipping the nested ones is exactly the authorization-
bypass risk this threat model calls out. - Common wrong turn: validating shape (depth, complexity) but never testing it. A depth limit
that was implemented but never covered by a test asserting it actually rejects an over-depth
request is a control that looks present in code review and absent in practice the first time it
matters.
Unlock Full Question Bank
Get access to all 7 API Security, Authentication and Authorization interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.