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.
Explain the differences between input validation, schema/contract validation (OpenAPI/JSON Schema), and output encoding. Give concrete examples of how each prevents different attack classes such as SQL injection, XSS, and parameter pollution, and list common developer mistakes that lead to validation bypasses.
Sample Answer
Direct answer
Input validation checks that a value is well-formed for the field it fills (a phone number
looks like a phone number); schema or contract validation (commonly using OpenAPI, a
specification format for describing an API's shape, or JSON Schema, a specification for
describing the structure of JSON data) checks that an entire request or response matches the
API's declared shape (required fields present, correct types, no unexpected extra fields); output
encoding transforms data on the way out so that a value which is safe as data cannot be
reinterpreted as code by whatever consumes the output (a browser rendering HTML, a database
executing a query). They are complementary layers, not substitutes for each other, and each
blocks a different attack class.
Structured elaboration
Input validation. Confirms an individual value conforms to expected format, range, and type
before it is used: a string that should be an email address actually looks like one, a quantity
field is a positive integer, a date is a real calendar date. This is the first line of defense
against malformed or unexpected data reaching business logic, but on its own it does not
guarantee an entire request is well-formed (a required field could simply be missing) or that
data is safe wherever it eventually ends up being used.
Schema and contract validation. Validates the request or response as a whole against a
formal contract: every required field present, every field the correct type, no unrecognized
fields silently accepted. Enforcing this at the API boundary (often automatically, since an
OpenAPI or JSON Schema definition can generate a validator) catches an entire category of bug
before it reaches handler code at all, and it directly prevents mass assignment style problems,
where an unexpected extra field in a request body ("isAdmin": true tacked onto a normal
profile-update payload) gets silently accepted and bound onto an internal object because nothing
rejected the unexpected field in the first place.
Output encoding. Transforms data based on where it is being written to, so that untrusted
data embedded in an output cannot be reinterpreted as a command by whatever parses that output.
The same string needs different encoding depending on its destination: HTML-encoding before
writing into an HTML page (so <script> in a stored value renders as visible text, not as an
executed tag), parameterization (not encoding) before using a value in a SQL query, so the
database engine never treats the value as part of the query's syntax.
How each prevents a different attack class:
- SQL injection is fundamentally an output-encoding problem, even though it is often
discussed as if it were an input-validation problem: a value that passes every reasonable
input-validation rule (a legitimate-looking last name likeO'Brien) can still break a
hand-built SQL string if it is concatenated directly into the query rather than passed as a
parameter. Parameterized queries (or an ORM that parameterizes for you) are the actual fix,
since they keep the value as data and never let it become part of the query's syntax,
regardless of what characters it contains. - Cross-site scripting (XSS) is an output-encoding problem specifically for the HTML/JS
context: a stored value that was correctly input-validated as "a non-empty string under 500
characters" can still contain<script>alert(1)</script>, and only encoding at render time
(or a strict content-security policy as defense in depth) stops that string from executing as
code when rendered in a browser. - Parameter pollution (sending the same parameter name multiple times, or in an unexpected
location, to see which value a poorly-specified handler actually uses) is primarily a
schema/contract validation problem: a strict schema that specifies exactly one value is
expected per field, and rejects a request that violates that shape, closes the ambiguity that
parameter pollution exploits.
Common developer mistakes that lead to validation bypasses:
- Validating on the client side only (in JavaScript in a browser, for example) and trusting that
client-side check server-side; any client-side validation is a request the server never sees
if the caller bypasses the browser entirely. - Validating a nested or optional field's presence but not revalidating its contents once it
is inside a larger, already-validated object. - Doing input validation and then building output by string concatenation anyway (
f"SELECT * FROM users WHERE name = '{name}'"), which discards everything input validation bought you the
moment it hits the output boundary, since input validation was never the layer responsible for
safe output construction in the first place. - Allowlisting only some fields in a schema (
additionalProperties: truein JSON Schema, or the
API framework's default of silently accepting unknown fields) rather than explicitly rejecting
unrecognized fields, which reopens the mass-assignment gap schema validation is supposed to
close.
Worked example
A profile-update endpoint expects {"displayName": string, "bio": string}. An attacker sends
{"displayName": "Jane", "bio": "hi", "isAdmin": true}.
- Input validation alone (checking
displayNameandbioare non-empty strings under some
length) says nothing about theisAdminfield, since input validation as commonly implemented
checks the fields it knows about, not the absence of fields it does not. - Schema validation with
additionalProperties: falserejects the whole request outright,
becauseisAdminis not a field the schema declares, closing the mass-assignment path before
it ever reaches the handler. - Separately, if
displayNameis later rendered on a public profile page as
<h1>{displayName}</h1>with no output encoding, an attacker who sets
displayName = "<img src=x onerror=alert(1)>"(a value that legitimately passes "non-empty
string under some length") gets that payload executed in every visitor's browser; only
HTML-encoding at render time (or an equivalent templating engine that encodes by default)
closes that specific gap, and it closes it regardless of what input validation rule was or was
not applied at write time.
Trade-offs and pitfalls
- These three layers fail independently, so skipping any one leaves a real gap, not a
redundant one: a request can pass schema validation perfectly (every field the right shape)
and still carry a payload that is dangerous purely because of where it later gets rendered or
interpreted, which is exactly why output encoding cannot be replaced by "we already validated
the input." - Overly strict schema validation has a real cost. Rejecting anything not explicitly listed
in the schema (additionalProperties: false) is the right default for security, but it also
means any new field a client starts sending, even a harmless one, breaks until the schema is
updated; this is a deliberate trade of forward-compatibility for safety, worth stating rather
than treating as a free win. - Common wrong turn: encoding once, at input time, and trusting it downstream. Encoding for
the wrong destination (or encoding too early, before the value passes through another layer
that expects raw data) is a frequent source of both broken functionality and residual
vulnerability; encode as close as possible to the point of output, for the destination that
specific output is going to.
Implement a thread-safe in-memory token-bucket rate limiter in Python. Provide functions:
- set_rate(key: str, tokens_per_sec: float, burst: int)
- acquire(key: str) -> bool
Requirements: allow bursts up to 'burst', refill at tokens_per_sec, be concurrency-safe (use threading.Lock), and avoid unbounded memory growth (evict idle keys).
Sample Answer
Direct answer
A token-bucket limiter keyed by client gives each key its own bucket that starts full (up to
burst tokens) and refills continuously at tokens_per_sec; acquire() lazily refills the
bucket based on elapsed time since it was last touched, then takes one token if at least one is
available. Concurrency safety comes from guarding all bucket reads and writes with a single
lock (or one lock per bucket, for less contention under many distinct keys); unbounded growth is
avoided by tracking each bucket's last-used time and periodically evicting buckets that have
been idle past a TTL.
Structured elaboration
Data model. Each key maps to a small record: current tokens, the configured rate and
burst, and two timestamps: last_refill (last time tokens were topped up) and last_used
(last time the key was touched at all, which drives eviction). Storing floating-point tokens
rather than integers lets sub-second refills accumulate correctly instead of always rounding to
zero.
Lazy refill. Rather than running a background thread that ticks every bucket on a timer
(wasteful for keys nobody is calling), acquire() computes elapsed time since the bucket's
last_refill, adds elapsed * rate tokens capped at burst, and only then checks whether a
token is available. This keeps idle keys cheap: a key that goes untouched for an hour costs
nothing until the next acquire() call recomputes its refill in one step.
Thread safety. All bucket state (create-if-missing, refill, and decrement) must happen
under one critical section per key, or two threads racing on the same key could both read
"1 token available" and both decrement, granting two requests off of one token. A single
process-wide threading.Lock around all bucket operations is simplest and correct; if lock
contention across many distinct keys becomes a bottleneck, a lock striped by key (or a lock
embedded in each bucket's own record) reduces contention while keeping each individual key's
operations atomic.
Bounded memory. Without eviction, every distinct key you ever see creates a permanent
dictionary entry, which is unbounded if keys are, for example, per-IP or per-API-key with high
cardinality. Track last_used per bucket and provide a sweep (evict_idle) that drops any
bucket untouched for longer than an idle TTL; call this sweep periodically (on a timer, or
opportunistically on a fraction of acquire() calls) rather than on every single call, so the
sweep cost is amortized.
Reconfiguration. set_rate() on an existing key should not reset an in-flight bucket back to
full, since that would let a client dodge a rate cut by triggering a reconfiguration; it should
keep the current fill level but clamp it down to the new burst ceiling if the new burst is
smaller than the current token count.
Worked example
import threading
import time
class TokenBucketLimiter:
# Thread-safe in-memory token-bucket rate limiter, keyed by client id.
# Each key gets its own bucket holding up to `burst` tokens, refilling
# at `tokens_per_sec`. An idle bucket (no acquire() past `idle_ttl`
# seconds) is evicted on the next sweep so memory does not grow forever.
def __init__(self, idle_ttl=300.0, clock=time.monotonic):
self._clock = clock # injected so a test can control time deterministically
self._idle_ttl = idle_ttl
self._lock = threading.Lock()
self._buckets = {} # key -> {tokens, rate, burst, last_refill, last_used}
def set_rate(self, key: str, tokens_per_sec: float, burst: int) -> None:
with self._lock:
now = self._clock()
bucket = self._buckets.get(key)
if bucket is None:
self._buckets[key] = {
"tokens": float(burst), "rate": tokens_per_sec, "burst": burst,
"last_refill": now, "last_used": now,
}
else:
bucket["rate"] = tokens_per_sec
bucket["burst"] = burst
bucket["tokens"] = min(bucket["tokens"], burst)
def acquire(self, key: str) -> bool:
with self._lock:
bucket = self._buckets.get(key)
if bucket is None:
return False # unknown key: no rate configured, deny by default
now = self._clock()
elapsed = now - bucket["last_refill"]
if elapsed > 0:
refill = elapsed * bucket["rate"]
bucket["tokens"] = min(bucket["burst"], bucket["tokens"] + refill)
bucket["last_refill"] = now
bucket["last_used"] = now
if bucket["tokens"] >= 1.0:
bucket["tokens"] -= 1.0
return True
return False
def evict_idle(self) -> int:
with self._lock:
now = self._clock()
stale = [k for k, b in self._buckets.items() if now - b["last_used"] > self._idle_ttl]
for k in stale:
del self._buckets[k]
return len(stale)
def bucket_count(self) -> int:
with self._lock:
return len(self._buckets)
# deterministic fake clock so the demo is reproducible: no real sleeping
fake_time = [0.0]
def clock():
return fake_time[0]
limiter = TokenBucketLimiter(idle_ttl=10.0, clock=clock)
limiter.set_rate("user-A", tokens_per_sec=2.0, burst=3)
# burst=3: first 3 calls at t=0 should succeed, the 4th should be denied
results_at_t0 = [limiter.acquire("user-A") for _ in range(4)]
print("t=0 acquires (burst=3):", results_at_t0)
# advance the fake clock by 1.0s -> refill = 1.0 * 2.0 tokens/sec = 2 tokens
fake_time[0] += 1.0
results_after_1s = [limiter.acquire("user-A") for _ in range(3)]
print("t=1.0 acquires (2 tokens refilled):", results_after_1s)
# concurrency check: 50 threads race for a bucket with burst=10, rate=0
limiter.set_rate("user-B", tokens_per_sec=0.0, burst=10)
granted = []
glock = threading.Lock()
def worker():
ok = limiter.acquire("user-B")
with glock:
granted.append(ok)
threads = [threading.Thread(target=worker) for _ in range(50)]
for t in threads: t.start()
for t in threads: t.join()
print("concurrent acquires granted out of 50 (burst=10, rate=0):", sum(granted))
# eviction: user-C goes idle past the 10s ttl while user-A stays active
limiter.set_rate("user-C", tokens_per_sec=1.0, burst=1)
print("bucket_count before idle advance:", limiter.bucket_count())
fake_time[0] += 11.0
limiter.acquire("user-A") # touches user-A so it stays "used" at the new time
evicted = limiter.evict_idle()
print("evicted idle buckets:", evicted)
print("bucket_count after eviction:", limiter.bucket_count())
Output:
t=0 acquires (burst=3): [True, True, True, False]
t=1.0 acquires (2 tokens refilled): [True, True, False]
concurrent acquires granted out of 50 (burst=10, rate=0): 10
bucket_count before idle advance: 3
evicted idle buckets: 2
bucket_count after eviction: 1
The first 3 calls consume the full burst and the 4th is denied, exactly matching burst=3.
After advancing the fake clock by 1 second at tokens_per_sec=2.0, exactly 2 tokens refill, so 2
of the next 3 calls succeed. With rate=0 (no refill) and burst=10, 50 threads racing
concurrently still only grant exactly 10 tokens total, which is the direct evidence that the
lock is preventing a race where more than burst requests get through. Finally, after advancing
past the 10-second idle TTL and touching only user-A, eviction removes the 2 buckets
(user-B, user-C) that went untouched, leaving only the 1 active bucket.
Trade-offs and pitfalls
- A single global lock is simple but becomes a bottleneck under many distinct keys with high
concurrency. If profiling shows lock contention, the fix is per-key locking (aLockinside
each bucket record, taken only for that bucket's own read-modify-write) or sharding the key
space across several independently-locked dictionaries, not removing the lock. - In-memory state means the limiter is per-process. Behind a load balancer with multiple
application instances, each instance enforces its own independent limit, so the effective
limit across the fleet is roughlyconfigured_limit * instance_count. That is often
acceptable for a soft, per-instance guard, but a hard global limit needs a shared store
(Redis, for example) with the same token-bucket logic implemented atomically there instead. - Common wrong turn: resetting
tokenstoburston everyset_rate()call. That lets a
client bypass a rate reduction simply by triggering any reconfiguration; only clamp the
existing token count down to the new burst ceiling, never reset it upward. - Pitfall: forgetting the "unknown key" case. Returning
True(allow) for a key with no
configured rate is a fail-open default that silently disables rate limiting for anything not
explicitly configured; the implementation above fails closed (denies) instead, which is the
safer default for a security-relevant control.
Explain the core OAuth 2.0 roles (resource owner, client, authorization server, resource server) and the common flows. For each actor below map the role and justify the flow choice:
- mobile app
- backend API
- third-party web app
- end user
Also explain when to use Authorization Code (with PKCE), Client Credentials, and when to avoid the Implicit flow.
Sample Answer
Direct answer
OAuth 2.0 defines four roles: the resource owner (the user who owns the data), the client (the
application requesting access), the authorization server (issues tokens after authenticating the
resource owner and getting their consent), and the resource server (the API that holds the data
and accepts the token). Which flow (grant type) a client uses follows directly from what kind of
client it is: a mobile app or single-page web application (SPA) is a "public" client that cannot
keep a secret, so it uses Authorization Code with PKCE (Proof Key for Code Exchange); a backend
service calling another API with no end user involved uses Client Credentials; a third-party web
app acting on behalf of a signed-in user uses Authorization Code (also with PKCE, since PKCE is
now recommended for every client type, not only public ones); and the end user is always the
resource owner, never a role that itself "chooses a flow."
Structured elaboration
Mapping each actor to its role and flow:
- Mobile app: a public client (it ships to end-user devices, so it cannot embed a secret that
stays confidential). It acts as the client role, uses Authorization Code with PKCE: the app
redirects the user to the authorization server to authenticate and consent, receives a
one-time authorization code back, then exchanges that code (plus a locally generated proof
value) for tokens directly with the authorization server. - Backend API: typically plays the resource server role (the earlier three actors send it
requests carrying a token) or the client role when it needs to call another service on its
own behalf, with no end user present, in which case it uses Client Credentials: it
authenticates directly to the authorization server with its own credential and receives an
access token representing itself, not any user. - Third-party web app: the client role, acting on behalf of a signed-in user. If it has a
confidential backend component that can hold a secret safely (a traditional server-rendered
web app), it uses Authorization Code (still layering PKCE on top, as current guidance
recommends for all clients); if it is a pure browser-side SPA with no confidential backend, it
is treated as a public client, same as the mobile app case. - End user: the resource owner. The end user does not "pick a flow"; they authenticate to the
authorization server and grant (or deny) consent, and the flow the client used determines what
they see during that step.
When to use each core flow:
- Authorization Code (with PKCE): the default choice whenever a human resource owner needs
to authenticate and grant consent, for both public and confidential clients under current
guidance. PKCE adds a locally generated secret (the code verifier) and its derived hash (the
code challenge) so that even if the authorization code itself is intercepted in transit, an
attacker cannot redeem it without also having generated the matching verifier. - Client Credentials: the machine-to-machine case, no end user in the loop at all; the client
authenticates as itself and receives a token scoped to what that client (not a user) is allowed
to do. - Implicit flow: avoid it. It returned access tokens directly in the URL fragment with no
authorization-code exchange step, which meant no client secret and no proof-of-possession check
were ever required, making tokens easier to leak (browser history, referrer headers, logs) and
easier to intercept than the Authorization Code flow's exchange step. It has been formally
deprecated in OAuth 2.1 guidance in favor of Authorization Code with PKCE, which now covers the
public-client use case the Implicit flow was originally created for, with none of its exposure.
Where to store tokens securely, since it differs meaningfully by client type. For an SPA,
there is no fully safe place to persist a token long-term in the browser: localStorage is
readable by any script on the page, which makes it directly exposed to a cross-site scripting
(XSS) vulnerability anywhere on the site; an in-memory-only access token (held in JavaScript
variables, gone on page refresh) paired with a refresh mechanism handled by a
backend-for-frontend (a thin server-side component the SPA talks to, which holds the actual
refresh token in an HttpOnly cookie the browser's own JavaScript cannot read) is the safer
current pattern. For a confidential backend client, refresh and access tokens are held
server-side, never sent to a browser at all, encrypted at rest, and scoped to that specific
client's own identity, which is a fundamentally different trust environment than a public
client's browser sandbox.
Worked example
sequenceDiagram
participant U as User
participant App as Client App
participant AS as Authorization Server
participant RS as Resource Server
App->>App: generate code_verifier, derive code_challenge
App->>AS: GET /authorize with code_challenge, client_id, redirect_uri
AS->>U: Login and consent prompt
U->>AS: Approves
AS->>App: redirect with authorization code
App->>AS: POST /token with code and code_verifier
AS->>AS: verify code_verifier matches stored code_challenge
AS->>App: access_token and refresh_token
App->>RS: API call with access_token
RS->>App: protected resource
Walking the mobile-app case through this diagram: the app (client) generates code_verifier
locally and derives code_challenge from it before the user ever sees a login screen. The
/authorize request carries only the challenge, never the verifier. After the user (resource
owner) authenticates and consents at the authorization server, the redirect back to the app
carries a short-lived authorization code, not a token. Only the app's own /token exchange,
which must present the original code_verifier, can turn that code into real tokens; an attacker
who intercepted the redirect and captured the code alone cannot complete this exchange without
also having captured the verifier, which never left the app's memory. The resource server then
accepts the resulting access token exactly like it would for a confidential client's token, since
from the resource server's point of view, a validly issued token is a validly issued token
regardless of which flow produced it.
Trade-offs and pitfalls
- PKCE protects the authorization-code exchange step specifically; it does not solve where a
public client stores the resulting tokens afterward. Those are two separate problems, which
is exactly why the token-storage question above (in-memory plus a backend-for-frontend for an
SPA) matters as its own decision, not something PKCE already covers. - Per-flow mitigations worth naming explicitly: Authorization Code with PKCE mitigates
code-interception attacks; Client Credentials should always run over a channel that itself
authenticates the client strongly (mutual TLS or a signed JWT assertion rather than a static
shared secret sent as plaintext, where the deployment's risk profile warrants it); and any flow
should always use short-lived access tokens with a separate, more tightly controlled refresh
token, so a leaked access token expires quickly on its own even if revocation is delayed. - Common wrong turn: treating "we use OAuth" as equivalent to "we made the right flow choice
for this client." A backend service using Authorization Code as if a human were involved when
no human is present adds unnecessary complexity and a dependency on an interactive login step
that has no one to complete it; Client Credentials is the correct, simpler fit. - Common wrong turn: storing an SPA's tokens in
localStoragefor developer convenience. It
works in testing and is the single most common real-world OAuth implementation mistake for
browser-based clients, precisely because it is the easiest thing to write and the failure mode
(any injected script can read every token) only shows up once there is an actual XSS
vulnerability elsewhere on the site to exploit it.
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.
Build an authentication and authorization scheme for a multi-tenant API that supports bearer tokens for user auth and API keys for service-to-service calls. Include per-tenant rate limits, key rotation, revocation, secure key storage, and how to represent tenant scoping in tokens or claims.
Sample Answer
Represent the tenant as a first-class claim (tid) inside every bearer token, so the API gateway can enforce tenant isolation without an extra database lookup on the hot path, and treat API keys for service-to-service calls as a separate credential type: hashed at rest, bound to exactly one tenant at issuance, rotated by versioning rather than in-place mutation.
Two credential types, one enforcement point
- User bearer tokens: OAuth 2.0 Authorization Code flow (the user logs in and consents at the identity provider, which redirects back with a one-time code that the app then exchanges server-side for a token) issues a short-lived JSON Web Token (JWT), 5-15 minutes, with claims
sub(user id),tid(tenant id), androles/scopes. - Service API keys: a high-entropy key id plus secret, sent as
Authorization: ApiKey <id>.<secret>, stored hashed server-side (for example with Argon2id), with a metadata row mappingkey_id -> tenant_id, scopes, status. - Both paths terminate at one API gateway, which extracts a normalized identity and tenant context (
X-Tenant,X-Scopesheaders) and forwards it downstream, so backend services never re-implement auth.
Tenant scoping in tokens
Whether the credential is a JWT's tid claim or an API key's tenant_id metadata row, both resolve to the same enforcement check: every downstream query filters WHERE tenant_id = :tid, and the gateway rejects any request whose payload references a different tenant_id than the token's own, before it ever reaches business logic. This is the tenant-boundary version of the same defense used against broken object-level authorization (a caller reaching another user's specific record just by guessing or changing its id) at the user boundary.
Per-tenant rate limits
Use a token bucket keyed by tenant_id, not by individual API key, because a tenant with several service accounts should share one quota. A per-key-only limit lets a tenant multiply its effective throughput just by minting more keys. Store buckets in a shared cache (for example Redis) keyed tenant:{tid}, with the refill rate set per pricing tier.
Key rotation and revocation
API keys are versioned (key_id:v1, v2, ...). Rotation creates a new version ACTIVE, marks the old one DEPRECATED for a grace window, then REVOKED. The revocation list is cached at the gateway and refreshed on a short interval or via an event push. Signing keys for JWTs rotate through a published JWKS (JSON Web Key Set) endpoint with overlapping key IDs, so tokens signed with the outgoing key still validate until they naturally expire.
Secure key storage
API key secrets are hashed (never stored or logged in plaintext) and shown in cleartext exactly once, at creation. Signing keys live in a key management service (KMS) or hardware security module (HSM); application code never touches the private key material directly.
Worked example
Tenant acme (tid=acme-042) runs a service account calling a billing API at 40 requests/second peak. Rate-limit config: 50 requests/second sustained, burst 100. The token bucket starts at 100 tokens and refills at 50/second, each request consuming one token. At a sustained 40 req/sec, the bucket never empties (refill 50 exceeds consumption 40), so no throttling occurs. If acme mints a second service account and the two together push 90 req/sec, the shared tenant-level bucket still throttles correctly at 50 req/sec sustained, whereas a per-key-only limit of 50 each would have let the tenant reach 100 req/sec simply by adding a key, defeating the purpose of a tenant-level quota.
Trade-offs and pitfalls
Putting tid only in application logic (not the token) forces a lookup on every request; putting it in the token is faster, but a tenant reassignment then requires reissuing tokens rather than just updating a database row. API keys are simpler for automation but riskier if leaked, since they carry no built-in expiry, mitigate with short rotation cadence and IP allowlisting per key. The common pitfall shown above is rate-limiting per API key instead of per tenant, which lets a tenant multiply its effective quota by minting more keys.
Unlock Full Question Bank
Get access to all 20 API Security, Authentication and Authorization interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.