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.
Lay out the authentication and authorization model for internal data APIs that must enforce fine-grained access controls per dataset, integrate with corporate SSO, audit all accesses, and support service-to-service auth with rotating credentials. Discuss token lifetimes, caching auth decisions, centralized policy engines (OPA), and audit log design for compliance.
Sample Answer
Authenticate humans through corporate single sign-on (SSO, one login your company already manages, federated into this system) and authenticate services through short-lived, auto-rotated credentials, but make the actual per-dataset access decision in one centralized policy engine, Open Policy Agent (OPA), so "which datasets can this caller see" has a single, auditable source of truth instead of being scattered across each service's own code.
Authentication
Users authenticate via SSO using OpenID Connect (OIDC, an identity layer on top of OAuth 2.0), issuing short-lived ID tokens. Service-to-service calls authenticate with short-lived, rotating credentials, either mutual TLS certificates issued by an internal certificate authority (following the SPIFFE/SPIRE pattern (an open standard for automatically issuing and rotating short-lived, verifiable service identities, so services get a strongly-verified identity without a team hand-managing individual certificates)) or signed JWTs from an internal token service, rotated automatically on the order of hours, not months.
Fine-grained access control per dataset
Centralize the actual decision in OPA (a general-purpose policy engine) acting as the Policy Decision Point (PDP): policies reference dataset metadata from a data catalog (owner, sensitivity classification, allowed purposes) plus the caller's identity and role. Enforce the decision at a Policy Enforcement Point (PEP) embedded in each data service, applying row or column filters, or an outright deny, before data leaves the service boundary, not after.
Token lifetimes
User ID tokens are short-lived, around 15 minutes, with revocable refresh tokens and back-channel logout, so ending a corporate SSO session actually ends access to this system too. Service credentials are short-lived, 1-4 hours, scoped narrowly, and auto-rotated.
Caching auth decisions
Cache PDP decisions at the PEP with a short TTL keyed on (principal, resource, action, policy-version), and invalidate on any policy or role change via an event, not by waiting out the TTL, since a stale allow cached past a revoked grant is a real access-control gap, not just a performance quirk. For the most sensitive datasets, skip the cache entirely, calling the PDP fresh every time, and accept the added latency as the price of a policy that can never be stale.
Audit log design for compliance
Every access emits one structured, immutable event: timestamp, principal, dataset or table (with any row/column filter applied), decision, the policy version used, and the count of rows actually returned. Storing the policy version alongside the decision lets an auditor later reconstruct exactly which rule allowed a given access, even after the policy has since changed. Stream events to an append-only store, object storage with a write lock or an equivalent immutable log, separate from the systems being audited.
Worked example
An analyst with role regional_analyst (EU) queries a dataset containing customer records across regions. The catalog marks the dataset's region column as a policy input. The OPA policy reads: allow if input.action == "read" and input.subject.region == input.resource.region. The analyst's SSO-issued token carries region: EU. Their query would naturally return rows for every region; the PEP, holding the PDP's decision, pushes a row filter WHERE region = 'EU' into the underlying query engine before execution, so the analyst never receives non-EU rows in the first place, rather than receiving them and being trusted not to look. The audit log for this access records the principal, dataset id, filter applied, policy version, and row count returned, for example 42,000 rows, enough for an auditor to later confirm the filter was actually enforced, not just intended.
Trade-offs and pitfalls
Centralizing every decision in OPA is auditable and consistent, but it makes OPA's availability a shared dependency for every data service, run it as a horizontally-scaled, stateless fleet with local caching so it doesn't become a single point of failure. Caching auth decisions cuts latency for high-throughput analytics workloads but is the most common place this design quietly breaks: a revoked grant that's still honored because the cache hasn't expired yet is a real incident, not just staleness, treat cache invalidation as a security control, not a performance nicety. Pushing filters into the query engine (predicate pushdown) scales well but requires trusting that engine to actually enforce the filter it's given, validate that trust with periodic tests that attempt, and expect to fail, an out-of-scope query.
Write a small Express middleware in Node.js that checks for a bearer token in the Authorization header, rejects requests with a 401 JSON response when missing or invalid, and logs request id and path. Provide the middleware signature and briefly describe how you'd integrate it into the request pipeline and unit-test it.
Sample Answer
A bearer-auth Express middleware uses the standard (req, res, next) signature: it extracts the Authorization header, rejects immediately with a JSON 401 body if it's missing or malformed, otherwise verifies the token and calls next(), logging the request id and path on every branch so failures are traceable without ever logging the token itself.
Approach
- Read the request id (or generate one) and the path up front, needed for logging regardless of outcome.
- Match
Authorizationagainst/^Bearer\s+(.+)$/i; a non-match means "missing or malformed", return 401 immediately without attempting token verification. - Verify the extracted token; a thrown error or falsy result means invalid, return 401.
- On success, attach the resolved identity to
req.userand callnext().
Code
// middleware/authBearer.js
const { verifyToken } = require('./tokenValidator'); // may be sync or async
function authBearer(req, res, next) {
const requestId = req.headers['x-request-id'] || 'unknown';
const path = req.originalUrl || req.url;
const header = req.headers['authorization'] || '';
const match = header.match(/^Bearer\s+(.+)$/i);
if (!match) {
console.warn(`[auth] reqId=${requestId} path=${path} result=missing_token`);
return res.status(401).json({ error: 'missing_bearer_token' });
}
const token = match[1];
Promise.resolve()
.then(() => verifyToken(token))
.then((user) => {
if (!user) {
console.warn(`[auth] reqId=${requestId} path=${path} result=invalid_token`);
return res.status(401).json({ error: 'invalid_token' });
}
req.user = user;
console.info(`[auth] reqId=${requestId} path=${path} result=ok userId=${user.id}`);
next();
})
.catch((err) => {
console.error(`[auth] reqId=${requestId} path=${path} result=validator_error message=${err.message}`);
res.status(401).json({ error: 'authentication_error' });
});
}
module.exports = authBearer;
Integration
Mount globally with app.use(authBearer) to protect every route registered after it, or per-route with app.get('/orders/:id', authBearer, handler) when only some routes need auth. Wrapping verifyToken in Promise.resolve().then(...) means the middleware works whether the underlying check is synchronous (a local JWT signature check) or asynchronous (a call to a remote auth service), without ever blocking Express's event loop.
Key points
The response body never distinguishes "missing" from "expired" from "bad signature", all three collapse to a generic 401 with a short error code, so an attacker learns nothing about which part of a forged token was wrong. Logging happens on every branch, including success, so a request id always maps to an auth decision in the logs, without the raw token ever appearing in them.
Complexity
Time is O(1) per request for the header parse and regex match; the dominant cost is whatever verifyToken does, a local signature check is roughly constant relative to token size, while a remote introspection call (asking the auth server over the network whether a token is still valid, instead of checking its signature locally) adds network latency, not algorithmic complexity. Space is O(1) beyond what Express already allocates per request.
Edge cases
A header present but not prefixed with Bearer (for example Basic ...) falls into the "missing" branch and returns 401. Duplicate Authorization headers are normalized by Node/Express to the first one (subsequent duplicates are silently discarded, not merged or overridden); if that matters for your threat model, reject the request outright instead of silently picking one. verifyToken throwing synchronously versus rejecting a promise are both caught by the same .catch, because the call is wrapped in Promise.resolve().then(...). A token that's valid but belongs to a since-disabled account can't be caught by this middleware alone, that check belongs to a separate authorization or session-revocation step downstream.
Unit testing
Using supertest and jest, with tokenValidator.verifyToken mocked: missing header returns 401 with missing_bearer_token; malformed header (Authorization: Token abc) returns 401; verifyToken resolving null returns 401 with invalid_token; verifyToken resolving a user object results in next() being called and req.user being set; verifyToken rejecting returns 401 with authentication_error, and the mocked logger's calls never contain the raw token string.
Trade-offs and pitfalls
Logging every failure at warn is useful for debugging but noisy under credential-stuffing traffic, pair it with rate limiting upstream rather than trying to fix that inside this middleware. Returning identical error codes for "missing" and "invalid" is a deliberate security trade-off (less information handed to an attacker) against debuggability (harder for a legitimate client's developer to self-diagnose); most public APIs accept that trade.
You need to implement rate-limited, audited access to an internal model endpoint for external contractors. Specify the OAuth flow, token scopes, per-call logging, and how you would detect suspicious use patterns that might indicate data exfiltration.
Sample Answer
Give each contractor a registered OAuth 2.0 client using the Client Credentials grant (a machine-to-machine flow with no interactive user) scoped down to exactly the model-inference action, with a short token lifetime, log every call with enough metadata to reconstruct who asked what without storing the full prompt or response by default, and detect exfiltration from a small set of per-client behavioral features moving together, rather than one hard threshold.
OAuth flow and token scopes
The Client Credentials grant lets the contractor's service authenticate directly with a client_id and client_secret (or, better, a mutual TLS client certificate) against the token endpoint, no human redirect is needed since there's no end user delegating access. Access tokens are short-lived JSON Web Tokens (JWTs), around 10 minutes, scoped to exactly what's needed, for example model.infer:readonly. No broader scope is granted by default; a higher-quota scope like batch inference requires a separate approval step.
Per-call logging
Log, per request: timestamp, request id, client id, org id, token id (jti, a unique id embedded in the token itself, so you can trace one specific token's activity even after it's expired), scope used, endpoint, a hash of the input rather than the raw input, input size, output size, latency, and model version. Storing the hash lets you later prove "this exact input was sent" without keeping sensitive content in plaintext logs; keep a separate, encrypted, access-controlled store of raw payloads for forensic replay only if an investigation actually needs it.
Detecting suspicious use or exfiltration
Rather than a single static threshold, compute a small set of per-client features over a rolling window and flag when several move together:
- request rate relative to that client's own multi-day rolling baseline, not a global threshold, since normal usage varies a lot by contractor
- output size distribution, a client whose average output size triples in a day is a stronger signal than one whose absolute output size is merely large
- query diversity, low diversity paired with high output volume looks like someone stitching together a large extraction across many near-identical calls
- unusual access time or source location relative to that client's own history
Worked example
Contractor client acme-ml-01 has a 7-day rolling baseline of 200 requests/day, average output 1.2 KB, from one known IP range. On day 8 the same client makes 1,800 requests (9 times baseline) with average output 3.6 KB (3 times baseline) from a new IP range. Define a simple combined risk score:
score = (requests_today / baseline_requests)
+ (avg_output_today / baseline_output)
+ (2 if new_ip_range else 0)
Plugging in the numbers: (1800 / 200) + (3.6 / 1.2) + 2 = 9 + 3 + 2 = 14. If the alert threshold is score >= 5, this client trips it by a wide margin, triggering automatic throttling to a lower rate limit plus a page to security on-call, rather than an immediate full revoke: a single feature spike alone (say, a legitimate batch job) shouldn't hard-block a contractor on its own, it's the compound score crossing the line that justifies notifying or throttling.
Trade-offs and pitfalls
Storing only a hash of the input by default protects privacy and reduces breach blast radius, but means you can't inspect the actual content during an investigation unless you've also kept a separate encrypted raw store, plan for both from day one rather than adding the raw store only after an incident. A single static rate threshold either misses low-and-slow exfiltration (many requests just under the limit, spread over days) or generates alert fatigue for bursty but legitimate contractors; per-client relative baselines fix the first problem, but need a full baseline window of history before they're trustworthy for a brand-new contractor. Auto-revoking on the first alert protects data but can wrongly cut off a contractor mid-task on a false positive, a graduated response (throttle, then human review, then revoke) balances the two.
That is every published API Security, Authentication and Authorization question for AI Engineer so far. Browse the other topics in this category, or practice this one interactively.