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.
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.
You need an automated emergency revocation and credential rotation plan for compromised client credentials affecting thousands of clients. What would you build? Include detection triggers, mass-revocation mechanics, phased rotation, client notification strategies, fallback modes to preserve critical functionality, and automated rollback if revocations cause unintended outages.
Sample Answer
Direct answer
At thousands of affected clients, a single big-bang revocation is itself a risk: if the
detection was a false positive, or if revocation triggers an unexpected downstream failure, you
have just caused a self-inflicted mass outage. The right design is a phased rollout (a small
canary batch revoked and monitored first, then the rest) with an automated error-rate check
gating the next phase, paired with a client-notification channel and a fallback mode that keeps
critical functionality alive during the rotation window.
Structured elaboration
Detection triggers. The plan starts before revocation: what actually declares "these
credentials are compromised." Realistic triggers include a leaked-secret scanner finding a key
committed to a public repo, an anomaly-detection alert showing a batch of credentials being used
from unexpected, correlated locations simultaneously, or a third-party breach disclosure naming
your credentials among leaked data. The trigger's confidence level should influence the response
speed: a directly confirmed leak (found in a public repo) justifies immediate action, while a
lower-confidence anomaly might justify starting the canary phase rather than a full mass
revocation.
Classify blast radius before acting. Determine which specific credentials, and which
clients, are actually affected, rather than revoking broadly "to be safe." Over-broad revocation
turns a contained incident into a bigger outage than the original compromise would have caused,
and makes the phased rollout's canary step meaningless if "the canary" is actually the entire
affected population already.
Mass-revocation mechanics and phased rotation. Revoke in stages: a small canary slice first
(for example 5% of affected clients), with automated monitoring of the immediate downstream
effect (authentication error rates, support ticket volume, dependent-service health) before
proceeding. If the canary phase looks clean, proceed to the remaining clients, ideally still in
batches rather than one further big step, so a problem discovered at 30% is cheaper to stop and
diagnose than one discovered at 100%.
Client notification strategies. Notify affected clients through more than one channel
(email, an in-dashboard banner, a status-page entry, and for programmatic integrations, an API
response that clearly signals "this credential is revoked, obtain a new one here" rather than a
generic authentication failure) since a silent revocation just looks like an outage from the
client's side and generates support load instead of self-service recovery.
Fallback modes to preserve critical functionality. For any client or integration where a hard
cutoff would cause serious harm (a payments integration going dark mid-transaction, for example),
consider a bounded grace period where the old credential is accepted for a small set of
lower-risk operations only (read-only calls, say) while write or sensitive operations require the
new credential immediately; this is a deliberate, scoped exception, not a general delay of the
whole revocation.
Automated rollback if revocations cause unintended outages. The phased design's real payoff
is here: the automated check gating each phase (error rate crossing a threshold, dependent-service
health degrading) should be able to pause or reverse the rollout automatically, re-enabling the
just-revoked batch's old credentials temporarily while the team investigates, rather than
requiring a human to notice the outage and intervene manually before further damage accrues.
Worked example
graph TD
Det[Detection: leak trigger] --> Class[Classify blast radius]
Class --> Phase1[Phase 1: revoke 5 percent canary]
Phase1 --> Check1{Error rate ok?}
Check1 -->|yes| Phase2[Phase 2: revoke remaining 95 percent]
Check1 -->|no| Rollback[Automated rollback plus alert]
Phase2 --> Notify[Client notification plus new credential issuance]
Notify --> Verify[Verify uptake, monitor auth failures]
With 10,000 affected client credentials: Phase 1 revokes 500 clients (5%) and holds for a defined
bake time (for example 15 minutes) while monitoring authentication error rates specifically among
that revoked cohort's expected traffic, not the whole system's aggregate rate, since a 5%
increase in a tiny slice can be invisible in an aggregate metric. If that cohort's error rate
stays within the expected "credential revoked, client has not rotated yet" range, and no
unrelated service's health degrades, Phase 2 proceeds to revoke the remaining 9,500. If instead
the canary phase shows an unexpected spike (say, a shared downstream dependency that was not
accounted for in the blast-radius classification starts failing), the automated gate halts the
rollout at 500 revoked rather than proceeding to 10,000, and the rollback path re-enables that
canary batch's old credentials temporarily while the team investigates what the classification
step missed.
Trade-offs and pitfalls
- A canary phase adds real time to full remediation, which is a genuine cost when the
underlying leak is actively being exploited; the trade-off against a faster full revocation is
explicit and should be a judgment call based on the trigger's confidence level (a confirmed
active exploitation may justify skipping straight to broader revocation despite the added
risk, where a lower-confidence anomaly does not). - A fallback mode that keeps old credentials working for "low-risk" operations is itself an
attack-surface decision, not a free safety net. If the attacker who obtained the leaked
credential can still use it for anything at all during the grace period, define precisely what
"low-risk" means for your system, since a wrong classification here (treating a read operation
that returns sensitive data as low-risk, for example) undermines the point of revoking at all. - Common wrong turn: treating rollback as a manual, human-triggered step. At the scale of
thousands of affected clients, by the time a human notices a rollout-caused outage and decides
to intervene, meaningful damage has usually already accrued; the automated gate needs the
authority to pause or reverse on its own, with the human notified, not consulted first. - Common wrong turn: measuring rollout health against a system-wide aggregate metric instead of
the specific affected cohort. A canary batch's problems are easy to miss in a global error
rate exactly because the canary is, by design, a small slice of total traffic.
How would you handle authentication token lifecycle and rotation for long-lived API clients such as IoT devices? Include token issuance, refresh, rotation, revocation, offline device handling, heartbeat strategies, secure storage on device, and methods for detecting and responding to token compromise.
Sample Answer
Direct answer
Treat an Internet of Things (IoT) device like a machine-to-machine client with a materially worse threat model than a server: it can be physically accessed, it cannot always phone home, and it typically has limited secure-storage hardware. The design centers on a strong, device-bound identity established once at provisioning, short-lived operational tokens refreshed opportunistically rather than on a fixed clock, and a revocation path that still works for a device that happens to be offline right now.
Structured elaboration
Issuance
At manufacturing or provisioning time, each device gets a unique, device-bound credential, ideally a private key generated on a hardware security element or secure enclave on the device itself, so the private key material never exists outside that chip, plus a certificate or registration record tying that key to a specific device ID. This is the root of trust everything else is refreshed from; it is not the day-to-day operational token.
Refresh
The device uses its long-lived provisioning credential to periodically obtain short-lived operational access tokens, brief, signed JSON Web Tokens (JWTs) that a receiving service can verify locally against a cached signing key rather than a long-lived static credential, refreshed well before expiry during normal connectivity so a brief network blip does not strand the device without a valid token.
Rotation
Operational tokens rotate frequently, short time-to-live values. The underlying device credential itself should also be rotatable on a much slower cadence, a scheduled re-provisioning or over-the-air credential rotation, so that even the root device identity is not a forever-secret, though this is the hardest piece operationally for a fleet with unreliable connectivity.
Revocation
Maintain a device-identity-level revocation list keyed on device ID, not on individual tokens, since a compromised device should lose all access, not just one token, checked at the point a device requests a new operational token. Because operational tokens are already short-lived, revocation does not need to reach an already-issued token instantly; it only has to stop the next refresh from succeeding, which bounds the compromise window to that token's remaining lifetime.
Offline device handling
A device disconnected for an extended period will have an expired operational token by the time it reconnects. Design the re-provisioning flow to work from the device's still-valid root credential, not requiring a human to physically re-touch the device, while still checking that root credential against the revocation list on every reconnect, so an offline period never becomes a loophole that skips the revocation check.
Heartbeat strategies
Devices send periodic, low-cost heartbeat or check-in calls, distinct from full operational traffic, that double as both a liveness signal for fleet management and a natural, low-frequency point to check the device's credential against the revocation list and pull a fresh operational token, without requiring constant high-frequency calls that would drain a battery-powered device.
Secure storage on device
The root credential's private key should live in hardware-backed secure storage, a Trusted Platform Module (TPM), secure enclave, or dedicated secure element chip, that resists extraction even with physical access, since an IoT device, unlike a typical server, can plausibly end up physically in an attacker's hands. If the hardware cannot support that, at minimum encrypt credentials at rest with a key derived from device-specific hardware characteristics, understanding that is a materially weaker guarantee than true hardware-backed storage.
Detecting and responding to compromise
Baseline expected behavior per device or device class, call frequency, data volume, which endpoints it normally calls, and alert on deviation. A sensor that normally reports once an hour suddenly polling every second, or calling an endpoint it has never called before, is a strong compromise signal. On a confirmed or suspected compromise, revoke at the device-identity level immediately, and for a fleet-wide vulnerability, a shared firmware bug rather than one stolen device, be prepared to force re-provisioning of the entire affected device class.
Worked example
A fleet of smart thermostats, each provisioned with a device-bound key at the factory, sends a heartbeat every fifteen minutes and refreshes its operational token alongside it. A firmware vulnerability is discovered that allows key extraction from a physically accessed unit. The response is to revoke at the affected batch or device-ID range level, force those specific devices through re-provisioning on their next heartbeat, and treat the vulnerable firmware version itself as a compromise indicator to actively hunt for across fleet telemetry, since any device still running it is a live exposure regardless of whether it has been physically tampered with yet.
Trade-offs & pitfalls
The most common and costly mistake is baking one long-lived, static API key into every device's firmware image: cheap to build, catastrophic to rotate, since it lives inside every unit ever shipped, and revoking it revokes the entire fleet at once. Heartbeat frequency is a genuine trade-off against a battery or bandwidth-constrained device's power budget, so the revocation-check cadence is coupled to a real cost, not a free design choice. Hardware-backed secure storage adds real bill-of-materials cost per unit, so cheaper device tiers sometimes skip it and inherit a materially worse compromise story; that trade-off should be made explicitly by the product and security teams together, not defaulted into silently.
Propose a policy-as-code solution (for example using Open Policy Agent - OPA) to enforce attribute-based access control across APIs. Describe where policies should evaluate (sidecar, gateway, service), policy distribution/versioning, performance strategies (caching/compile-time optimizations), CI testing of policies, and how to secure attribute sources and mitigate stale attributes.
Sample Answer
Direct answer
A policy-as-code approach to attribute-based access control (ABAC, where access decisions depend
on attributes of the user, resource, and context rather than a fixed role list) centers on Open
Policy Agent (OPA), a general-purpose policy engine, evaluating policies written in its Rego
language as close to the request as latency allows, most often colocated in a sidecar next to
each service, with policies distributed as versioned bundles from a central server and pulled
(or pushed) to every OPA instance on a schedule.
Structured elaboration
Where policies should evaluate. There are three realistic placement options, and they are
not mutually exclusive:
- Gateway: good for coarse, cross-cutting checks (is this caller authenticated at all, is
this API version deprecated) that apply uniformly regardless of which backend service handles
the request. - Sidecar (colocated with each service): the most common choice for fine-grained
authorization, since it evaluates in-process (a local network call at most) rather than
requiring a round trip to a shared service, and each service's sidecar can be configured with
only the policy bundle relevant to that service. - In-service (embedded as a library): lowest latency of all (no network hop, not even to a
sidecar) but couples the policy engine's lifecycle to the service's own deploys, which fits
latency-critical paths but loses some of the operational uniformity a sidecar gives you.
The general principle: evaluate as close to the request as the latency budget allows, and prefer
the sidecar pattern by default since it keeps policy evaluation decoupled from application code
without paying a network round trip to a remote service.
Policy distribution and versioning. Author policies as code, version them in the same way
application code is versioned (a git repository, code review, CI checks), and package them into
signed, versioned bundles that a bundle server serves to every OPA instance. Each OPA instance
polls for and pulls new bundle versions on an interval (or the server pushes them), so a policy
change rolls out without redeploying the services that consume it; the bundle's version is
itself an auditable artifact, so "which policy was in effect for this request" is answerable
after the fact.
Performance strategies. OPA compiles Rego policies before evaluation and can serve most
authorization decisions in well under a millisecond once a bundle is loaded, since the decision
is a local, in-memory evaluation, not a network call to an external service. For very
high-throughput paths, further techniques include partial evaluation (precompiling a policy
against known-at-compile-time inputs to shrink the runtime decision), and caching decisions for
identical inputs over a short, explicitly bounded window when the policy and the underlying
attributes are not expected to change within that window.
CI testing of policies. Treat Rego policies exactly like application code under test: write
unit tests (OPA's own test framework, opa test) asserting that specific input attribute
combinations produce the expected allow/deny decision, including deliberately adversarial and
edge-case inputs (a request with a missing or malformed attribute, a role that should never gain
a specific permission), and run those tests in CI before a policy bundle is built and published,
the same gate application code changes go through.
Securing attribute sources and mitigating stale attributes. ABAC decisions are only as
trustworthy as the attributes feeding them (a user's department, a resource's sensitivity
classification, a device's posture); if OPA is fed attributes forwarded from an upstream caller
without verifying their source, a caller can forge a favorable attribute directly. Attributes
should come from a trusted source the policy engine (or the service feeding it) independently
verifies, a signed token's claims, a lookup against a system of record, rather than an
unauthenticated header. Staleness is a related but separate risk: an attribute cached or fetched
minutes ago (a user's role, changed since) can produce a decision based on outdated reality;
mitigate it by keeping high-consequence attributes (role, employment status) on a short refresh
interval or fetched fresh for sensitive operations, while accepting a longer, cheaper refresh
interval for low-consequence, slow-changing attributes (a user's display name).
Worked example
graph TD
Req[API Request] --> GWEnf[Gateway - coarse policy]
Req --> Sidecar[Sidecar - per service policy]
Req --> App[In app - fine grained ABAC]
GWEnf --> OPA[OPA Engine - local decision]
Sidecar --> OPA
App --> OPA
Bundle[Policy Bundle Server] -.->|versioned push| OPA
Attr[Attribute Sources: user, resource, context] --> OPA
A concrete Rego-shaped decision (illustrative, not executed): a request to approve an expense
report carries attributes {user.department: "finance", user.role: "manager", resource.amount: 4500, resource.owner_department: "finance"}. A policy rule such as allow if input.user.role == "manager" and input.user.department == input.resource.owner_department and input.resource.amount <= 5000 evaluates entirely from attributes already present on the
request, with no network call needed at decision time, which is what keeps this pattern fast
enough to sit on the request path even at the sidecar. Changing the approval ceiling from 5000 to
a new value is a policy bundle change, published and rolled out to every sidecar, with no service
code redeployed.
Trade-offs and pitfalls
- ABAC's flexibility is also its main operational risk. Because rules combine arbitrary
attributes instead of a fixed role list, it is easy to write a policy that is logically correct
for the cases you tested and subtly wrong for a combination you did not, which is exactly why
CI testing of policies (including adversarial edge cases) is not optional here the way it might
feel optional for a small, fixed RBAC (role-based access control) rule set. - Colocated evaluation trades a small memory and CPU footprint per service for latency and
availability. A centralized policy-decision service is simpler to operate as a single thing,
but makes every authorization check depend on that service's availability and adds a network
hop to every request; the sidecar pattern avoids both costs at the price of running many small
OPA instances instead of one larger one. - Common wrong turn: trusting client-supplied or upstream-forwarded attributes without
verifying their source. A well-tested policy is still exploitable if an attacker can simply
set the attribute the policy checks; verify attribute provenance, not just the policy logic
itself. - Common wrong turn: refreshing all attributes on the same schedule. Treating a user's
display name and a user's active-employment status as equally safe to cache for, say, an hour
ignores that the second one directly gates access; calibrate refresh/staleness tolerance per
attribute by how consequential it is, not uniformly.
Propose detection and mitigation strategies for credential stuffing and automated account takeover attempts on login endpoints. Propose telemetry signals (IP velocity, failed-login patterns, device fingerprinting), anomaly detection heuristics, progressive throttling and challenge mechanisms (CAPTCHA, MFA step-up), and techniques to minimize false positives while blocking automated abuse.
Sample Answer
Direct answer
Credential stuffing (attackers replaying stolen username/password pairs from other breaches
against your login endpoint) and automated account takeover are detected primarily through
behavioral telemetry, not a single rule: velocity per IP and per credential, failed-login
patterns, and device fingerprinting feed an anomaly signal that drives progressive friction
(throttling, then a CAPTCHA, then a step-up to multi-factor authentication, MFA) rather than an
all-or-nothing block. The same abuse-defense posture extends past the login form: public API
endpoints that let a client scrape or excessively read data need their own rate and reputation
controls, and a confirmed compromise needs a remediation path (revoking affected credentials,
notifying affected customers), not just a detection alert.
Structured elaboration
Telemetry signals to collect.
- IP velocity and reputation: request rate per IP, and whether the IP is a known VPN,
residential proxy pool, or Tor exit node, since credential-stuffing tooling routes through
large proxy pools specifically to defeat simple per-IP rate limits. - Failed-login patterns: the tell-tale shape of credential stuffing is a low
success-to-attempt ratio spread across many distinct usernames, each tried only once or
twice, as opposed to a brute-force attack hammering one account with many password guesses; a
detector that only watches "failed attempts against one account" misses stuffing entirely. - Device fingerprinting: signals derived from the client (browser/TLS fingerprint, screen
and font characteristics for a web client) that let you notice the same automated client
reappearing under many different credentials or IPs.
Anomaly detection and progressive response. Combine the signals above into a risk score per
login attempt rather than a single hard rule, and respond proportionally:
- Low risk: allow normally.
- Medium risk: add friction, a CAPTCHA challenge or a short delay, cheap enough that it barely
affects a real user but meaningfully slows down high-volume automation. - High risk: require an MFA step-up even if the password was correct, since a correct password
from a breached credential list does not prove the caller is the legitimate account owner. - Confirmed automation: block or throttle hard at the network layer (the IP or proxy pool).
Minimizing false positives. Progressive challenges exist specifically so that a real user
having a bad day (typo'd password, unfamiliar network) experiences mild friction rather than a
hard lockout, while only sustained, clearly automated patterns escalate to a block. Tune
thresholds against a labeled sample of known-legitimate traffic (users on shared corporate
NATs, for example, who will always look like "many login attempts from one IP") before rolling
out a stricter rule broadly, and alert on the block rate itself, since a spike in legitimate
users getting blocked is a signal the thresholds are miscalibrated, not that abuse suddenly
increased.
Beyond the login endpoint: broader public-API abuse defense. The same abuse-defense posture
applies past login, since a public API that is not itself an auth endpoint can still be scraped
or read excessively (a competitor bulk-harvesting a product catalog or pricing data, for
example): rate limiting and quotas per API key or per identity, the same IP-reputation signals
described above applied to read-heavy endpoints, and CAPTCHA gating specifically on suspicious
UI flows (a signup form, a password-reset form) where a CAPTCHA is tolerable friction, rather
than on every API call, where it would break legitimate programmatic clients entirely. When a
compromise or sustained abuse campaign is confirmed, remediation extends beyond detection:
revoke or force-rotate the specific credentials or API keys involved, and notify affected
customers when their accounts or data were plausibly touched, since detection without a
remediation and notification path leaves the actual harm unaddressed even after the technical
attack is stopped.
Worked example
A login endpoint sees, over one hour: 40,000 login attempts from 6,000 distinct source IPs,
targeting 35,000 distinct usernames, with a 0.6% success rate. Contrast that shape with normal
traffic, where the success rate on real login attempts is typically well above 90%, and failures
cluster on a small number of accounts (someone mistyping their own password) rather than
spreading almost one-to-one across usernames. The "many usernames, each tried once or twice,
overall success rate far below normal" shape is the credential-stuffing signature; a system
tracking only "failed attempts per account" would see nothing unusual, since almost no single
account crosses a per-account threshold. A velocity-plus-fingerprint detector, by contrast, would
flag the aggregate pattern across usernames and IPs, trigger progressive CAPTCHA challenges on
the highest-risk slice of that traffic, and, once confirmed, feed the responsible IP ranges into
a longer-lived reputation block while triggering forced password resets for the small number of
accounts where a stuffing attempt actually succeeded.
Trade-offs and pitfalls
- Per-account thresholds alone miss credential stuffing by design, since the attack's whole
point is spreading load thin across many accounts specifically to stay under any single
account's threshold; detection has to look at the aggregate pattern across accounts, not just
within one. - CAPTCHA fatigue is a real cost. Applying CAPTCHA broadly, including to every API call
rather than only to suspicious UI flows, degrades the experience for legitimate users and
breaks legitimate automated integrations; reserve it for the specific flows and risk tiers
where it is proportionate. - IP reputation is a leaky signal on its own. Shared NATs, corporate networks, and mobile
carrier-grade NAT mean many legitimate users can share one IP; combine IP reputation with
device and behavioral signals rather than blocking on IP alone. - Common wrong turn: treating detection as the finish line. A confirmed stuffing success
(an attacker actually got into an account) still requires the remediation steps: forced
credential rotation for affected accounts, session invalidation, and customer notification
where applicable; a detector that only logs an alert leaves the compromised accounts open.
Unlock Full Question Bank
Get access to all API Security, Authentication and Authorization interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.