← AI + Backend
Core Backend Fundamentals

Authentication & Authorization

Watch: "OAuth 2.0 and OpenID Connect (in plain English)" — OktaDev

Authentication (authn) answers "who is this caller?"; authorization (authz) answers "what may this caller do, to which resource, right now?". Interviewers probe them together because most real security failures are not broken cryptography, they are a missing authorization check, a token that lives too long, a tenant id taken from the wrong place, or a secret shipped in the wrong tier. For a Backend or AI engineer the stakes are higher than for a typical CRUD API: a model endpoint spends real money per call, an agent may call tools that change data, and a retrieval system may hand one customer's documents to another if access control is applied after the fact. As an Android developer you have seen the client side of this (tokens stored on a device, refresh flows); this page is the server side and the trade-offs behind it.

The protocol-level basics (cookies, the Authorization header, TLS) are in Authentication & Authorization in Computer Networks, cookies and sessions and HTTPS and TLS. Here the focus is on how the pieces are chosen and where they fail.

What Is Authentication?

Authentication is the step where a system checks that you are who you claim to be — typing a password, scanning a fingerprint, or handing over a login token. It answers one question only: "is this really you?" Nothing about what you're allowed to do is decided yet; that's a separate step.

What Is Authorization?

Authorization is the step that happens after you're recognized: deciding what you, specifically, are allowed to see or do. Two people can pass the exact same authentication check (both are logged in) and still get very different authorization outcomes — one can view billing records, the other can't.

Where This Is Used

A Day-to-Day Example

Think of a hotel. Showing your ID at check-in and getting a room key is authentication — the front desk confirms you are who your reservation says. The key card only opening your room, the gym, and the pool, but not the manager's office or another guest's room, is authorization — it controls what that verified identity is allowed to access.

Deep Dive: Sessions vs JWTs (Stateful vs Stateless Credentials)

Session lookup versus self-contained tokenserver-side sessionself-contained JWTclientAPI serversession storeRedis / DBclientany serviceverify sig + explocally, no lookupcookie sidlookup / hopBearer JWT+ revoke instantly (delete entry)− store must be shared + available+ no store, any service verifies− valid until exp (no early revoke)
A session id means nothing on its own, so every request looks it up in a shared store (and deleting the entry revokes it instantly). A JWT carries signed claims, so any service verifies it locally but can't take it back before it expires. Storage and revocation details are below.
statefulstateless
SERVER-SIDE SESSION                               SELF-CONTAINED TOKEN (JWT access token)

login -> server creates random session id         login -> server signs {sub, tenant, scope, exp}
        stores {sid -> user, tenant, ...}                 returns header.payload.signature
        in Redis/DB, sets cookie: sid=...                  client sends it on every request

each request -> lookup sid in the store             each request -> verify signature + exp locally
   + one network hop per request                        no store lookup, any service can verify
   + revoke instantly: delete the entry                 - cannot revoke early: valid until exp
   + small opaque cookie, server controls data          - bigger header; claims can go stale (role changed?)
   - store must be highly available and shared          - if the signing key leaks, forge anything

Browser storage of the credential:
   HttpOnly + Secure + SameSite cookie : JavaScript cannot read it (limits XSS theft), needs CSRF defence
   localStorage / JS-readable          : any XSS can exfiltrate it, but no automatic CSRF
Illustrative revocation math (assume 15-min access tokens): a stolen JWT works for at most 15 minutes
unless you add a denylist; a session cookie can be killed in the next request.

The real question is where the state lives. A session keeps the truth on the server, so logout, password change and "ban this user" take effect immediately, at the price of a shared store (commonly Redis) that every request touches and that must not become a single point of failure. A JWT moves the truth into the token: the receiving service only needs the verification key, so a gateway and ten microservices can authenticate a request without a network call, which is why token-based schemes fit microservices and mobile clients. The bill arrives on revocation and staleness. Because nobody is asked at request time, a token remains valid until exp, and claims such as roles or plan are frozen at issue time. "JWT vs session" is therefore not old versus new; it is scalability of verification versus control of revocation, and most production systems land in between with short-lived JWTs plus a revocable refresh token.

For browsers, an HttpOnly, Secure, SameSite cookie holding either a session id or a token is generally safer against script theft than localStorage, but cookie-based auth needs CSRF protection (SameSite, CSRF tokens, checking Origin; see CORS). For mobile and machine clients there is no ambient cookie, so a bearer token in the Authorization header is the norm, stored in the platform secure store, with the reminder that anyone holding a bearer token can use it.

Deep Dive: Inside a JWT, Verification Rules, Expiry, Refresh and Revocation

JWT parts and the verifier's checklistbase64url, joined with dots: ENCODED, not encryptedheaderalg, typ.payloadsub, tenant, scope, aud, iat, exp.signatureHMAC / RSA / EC1. algpin it yourself2. signatureconstant-time3. exp / nbf+ clock skew4. iss + audthis API only5. claimsonly now readverifier checklist (all of them, in order)refresh rotation: each refresh returns a NEW refresh token;an old one presented again → theft suspected → revoke the whole family
Anyone can decode a JWT's header and payload; only the signature proves nobody changed them. A verifier pins the algorithm, checks the signature, the expiry, the issuer and audience, and only then trusts the claims. Refresh and revocation details are below.
claimssignature
JWT = base64url(header) . base64url(payload) . base64url(signature)
      header  {"alg":"HS256","typ":"JWT"}
      payload {"sub":"user_42","tenant":"acme","scope":"chat:read","aud":"llm-api","iat":..,"exp":..}
      HS256 signature = HMAC-SHA256(secret, header + "." + payload)     (symmetric: verifier can also mint)
      RS256/ES256 = private-key signature, public-key verification     (verifiers cannot mint; keys via JWKS)
The payload is ENCODED, not encrypted: anyone can read it. The signature only proves integrity.

Verifier checklist (do ALL):
  1. fix the allowed algorithm(s) yourself; never trust the token's "alg" (rejects alg=none, HS/RS confusion)
  2. verify the signature with a constant-time compare
  3. check exp (and nbf), allow small clock-skew leeway
  4. check iss (issuer) and aud (audience: this API, not just any API that trusts the same issuer)
  5. only then read sub / tenant / scope, and authorize against them

Access + refresh pair:
  access token  : short (minutes), JWT, sent on every API call, not revocable individually
  refresh token : long (days), opaque random, stored server-side (hashed), used only at the token endpoint
  ROTATION      : each refresh returns a NEW refresh token and invalidates the old one;
                  if an old one is presented again -> theft suspected -> revoke the whole token family
Revocation options: short exp (cheapest) | denylist by jti until exp | per-user "token version" claim
  Illustrative: 5 revocations/s x 15-min lifetime -> a denylist never exceeds ~4,500 entries (900 s x 5).

A JWT is just a signed JSON document, and the two most common mistakes come from forgetting that. First, the payload is readable by anyone, so it must not carry secrets. Second, the signature is only worth something if the verifier is strict: pinning the algorithm, checking expiry, issuer and audience, and comparing signatures in constant time. Accepting whatever alg the token declares has caused real vulnerabilities (the none algorithm, and confusing an RSA public key for an HMAC secret). HS256 is fine when issuer and verifier are the same trusted service; once several teams or third parties verify tokens, use an asymmetric algorithm so verifiers hold only a public key, published as a JWKS document that supports key rotation via a kid header.

Token lifetime is a dial with no free setting. Short access tokens shrink the theft window but force frequent refreshes and make the token endpoint a hot dependency; long ones are convenient and dangerous. The standard compromise is a short access token plus a refresh token that is rotated on each use, with reuse detection, so a stolen refresh token is caught the first time both the thief and the real client use it. If you need immediate revocation, you reintroduce state: a small denylist keyed by token id that only has to remember entries until they would expire anyway, or a per-user token version checked against a cache. Introspection (asking the issuer whether an opaque token is active) is the fully stateful end of the same spectrum. The runnable demo in Try It Yourself shows signing, tampering, expiry and algorithm rejection with only the standard library.

Deep Dive: OAuth 2.0 and OpenID Connect at a Working Level

Authorization Code flow with PKCEAppUserIdPAPIredirect + code_challengelog in + consentone-time codecode + code_verifieraccess (+ refresh, ID) tokenAuthorization: Bearer <access>
The app sends a hash of a secret verifier with the login redirect, and later proves it knows the verifier when swapping the code for tokens, so a stolen code alone is useless. With OIDC the IdP also returns an ID token saying who signed in. Other grants are below.
appidentity provider
OAuth 2.0 = DELEGATED AUTHORIZATION ("let app X act on my behalf with limited scope")
OpenID Connect (OIDC) = identity layer ON TOP of OAuth ("who logged in"), adds an ID token (a JWT)

Authorization Code + PKCE (browser/mobile users):
  1. app makes code_verifier (random), sends code_challenge = SHA256(verifier), redirects user to the IdP
  2. user logs in at the IdP and consents to scopes
  3. IdP redirects back with a one-time authorization code
  4. app POSTs code + code_verifier to the token endpoint  -> access token (+ refresh token, + ID token if OIDC)
  5. app calls the API with  Authorization: Bearer <access token>
  PKCE means an intercepted code is useless without the verifier (important for mobile and SPAs: no client secret)

Client Credentials (service-to-service, no user):
  service authenticates with its own credentials -> access token for the service identity, scoped

Device Code: TVs / CLIs without a browser.       Refresh Token grant: renew without user interaction.
Avoid: Implicit and Resource-Owner-Password grants (dropped or discouraged in current OAuth guidance).

Tokens are not interchangeable: ID token = "who signed in" for the client app;
access token = "what the API will allow" and is meant for the resource server.

The vocabulary interviewers want you to keep straight: the resource owner (the user), the client (the app), the authorization server / identity provider (issues tokens), and the resource server (your API). OAuth 2.0 by itself says nothing about who the user is, only that a client was granted certain scopes; OIDC adds a standard ID token, discovery document and userinfo endpoint so "Sign in with Google/Microsoft" is interoperable. In practice, a public client (mobile, single-page app) uses Authorization Code with PKCE, because it cannot keep a secret; a backend job or another service uses Client Credentials; and the API validates the access token exactly as in the checklist above. The current security guidance (and the OAuth 2.1 draft) consolidates these choices: PKCE everywhere for the code flow, exact redirect URI matching, no implicit flow.

Where it breaks: redirect-URI validation that is too loose (token or code leaks to an attacker's URL), missing state (login CSRF), using an ID token as if it were an API access token, accepting tokens minted for a different audience, and over-broad scopes granted "just in case". Note also the difference in trust: with a third-party identity provider your availability and session revocation partly depend on someone else, so keep the local session short and handle the IdP being down.

Deep Dive: API Keys vs OAuth, RBAC vs ABAC, and Multi-Tenant Authorization

Role-based and attribute-based authorizationRBAC: coarse capabilityuserrolepermissionsABAC: attributesuser, resource, action,context: tenant, plan, timepolicyallow / denyReBAC: owner / editor / viewertenant_id comes from the VERIFIED credential; every query is scoped by itchecked on the server for every action, per object (guards against IDOR)
Roles are easy to audit but explode when rules get fine-grained. Attribute policies can check tenant, plan and ownership directly. A common answer uses roles for coarse capability plus attribute checks for tenant and ownership. API keys versus OAuth are compared below.
RBACABAC
API KEY                                             OAUTH ACCESS TOKEN
  identifies a project/app, long-lived                 identifies a user and/or client, short-lived, scoped
  simple: header "Authorization: Bearer sk_..."        needs an IdP, flows, refresh, more moving parts
  store only a HASH; show the secret once              expiry and revocation are built into the model
  rotate by issuing a second key, then revoke old      delegation: acts on behalf of a user with consent
  fits: server-to-server, developer platforms          fits: user-facing apps, third-party access

Authorization models:
  RBAC  user -> role -> permissions            simple to audit; role explosion when rules get fine-grained
  ABAC  policy over attributes (user, resource, action, context: tenant, plan, time, model)
  ReBAC permissions from relationships (owner/editor/viewer of a doc, folder inheritance)
  Common answer: RBAC for coarse capability + ABAC-style checks for tenant, ownership and plan limits.

Multi-tenant rules:
  - derive tenant_id from the VERIFIED credential, never from a request field or URL
  - every query is scoped by tenant_id (row-level security or a mandatory repository filter)
  - authorization is checked on the SERVER for every action, at the object level (guard against IDOR)
  - per-tenant quotas and rate limits so one tenant cannot starve the rest

API keys and OAuth solve different problems, so the honest answer to "which one?" is "for whom?". A developer platform where customers call your model API from their servers is the natural home of API keys: simple, copy-pasteable, and easy to scope to a project. Treat a key like a password: generate it with a cryptographic random source, show it once, store only a hash (a plain fast hash is acceptable because the key itself is high-entropy, unlike a human password), include a non-secret prefix or id so lookup and leak scanning work, support several active keys per project for rotation, and make revocation immediate. When actions must be attributed to an end user, or a third party needs limited access without ever seeing credentials, use OAuth so access is scoped, expiring and revocable by the user.

On authorization, RBAC is easy to reason about and audit, but roles multiply as rules get finer ("editor, but only for their own team's documents, only on the paid plan"). ABAC expresses those as policies over attributes, and relationship-based systems model sharing and inheritance directly; real systems combine a coarse role check with attribute checks for ownership and tenant. In multi-tenant systems the dominant bug class is broken object-level authorization: an endpoint that checks "is the caller logged in" but not "does this object belong to the caller's tenant". The defence is structural, not per-endpoint diligence: tenant id comes from the verified token, every data access is tenant-scoped by construction (for example PostgreSQL row-level security or a repository layer that cannot be called without a tenant), and tests attempt cross-tenant access. The demo below hashes API keys and combines RBAC with tenant and model-plan attributes.

import hashlib, hmac, secrets

# --- API keys: show once, store only a hash, look up by a non-secret prefix ---
KEYS = {}                                   # key_id -> record (this is the "database")
def issue_key(tenant, scopes):
    key_id, secret = "k1" + secrets.token_hex(2), secrets.token_urlsafe(24)
    KEYS[key_id] = {"hash": hashlib.sha256(secret.encode()).hexdigest(), "tenant": tenant,
                    "scopes": set(scopes), "revoked": False}
    return f"sk_{key_id}_{secret}"          # returned to the customer exactly once

def authenticate(presented):
    try: _, key_id, secret = presented.split("_", 2)
    except ValueError: return None
    rec = KEYS.get(key_id)
    if not rec or rec["revoked"]: return None
    ok = hmac.compare_digest(rec["hash"], hashlib.sha256(secret.encode()).hexdigest())
    return rec if ok else None

# --- authorization: RBAC gives the coarse answer, an ABAC-style check adds tenant + attributes ---
ROLE_PERMS = {"viewer": {"chat"}, "developer": {"chat", "embed"}, "admin": {"chat", "embed", "keys:manage"}}
def allowed(principal, action, resource):
    if action not in ROLE_PERMS[principal["role"]]:              # RBAC
        return "DENY: role lacks permission"
    if resource["tenant"] != principal["tenant"]:                # tenant isolation (attribute)
        return "DENY: cross-tenant"
    if action == "chat" and resource["model"] not in principal["models"]:   # attribute: model allow-list
        return "DENY: model not in plan"
    return "ALLOW"

k = issue_key("acme", {"chat"})
print("stored hash len:", len(next(iter(KEYS.values()))["hash"]), "hex chars (secret itself is not stored)")
print("valid key      ->", bool(authenticate(k)))
print("tampered key   ->", bool(authenticate(k[:-1] + ("A" if k[-1] != "A" else "B"))))
KEYS[k.split("_")[1]]["revoked"] = True
print("after revoke   ->", bool(authenticate(k)))

p = {"tenant": "acme", "role": "developer", "models": {"small", "medium"}}
print(allowed(p, "chat",  {"tenant": "acme",   "model": "small"}))
print(allowed(p, "chat",  {"tenant": "acme",   "model": "large"}))
print(allowed(p, "chat",  {"tenant": "globex", "model": "small"}))
print(allowed(p, "keys:manage", {"tenant": "acme", "model": "small"}))
# Real output from python3:
# stored hash len: 64 hex chars (secret itself is not stored)
# valid key      -> True
# tampered key   -> False
# after revoke   -> False
# ALLOW
# DENY: model not in plan
# DENY: cross-tenant
# DENY: role lacks permission

Deep Dive: Securing AI Endpoints and Tool Calls

Tool calls carry the user's identity end to enduser tokenAPI gatewayagentuntrusted plannertool servicere-validates userthe tool service checks the delegated USER identity and scope itself;it never trusts "the agent said so" (confused deputy)prefer short-lived, narrowly scoped tokens minted per task
An agent with broad credentials can be steered by injected text into using them. Passing the user's delegated identity through, and having each tool service authorize against it, keeps the agent from doing anything the user couldn't. Other threats and controls are listed below.
untrustedenforces auth
Threats and controls specific to LLM systems:

  Leaked model-provider key       -> keep it on YOUR backend only (never in a mobile/web client);
                                     secrets manager, rotation, per-environment keys, spend alerts
  Cost abuse / denial of wallet   -> per-tenant AND per-key quotas in tokens and requests, hard monthly cap,
                                     max_tokens and input-size limits, anomaly alerts
  Cross-tenant data via RAG       -> enforce document ACLs AT RETRIEVAL (filter the vector query by
                                     tenant/user permissions); never rely on the model to "not mention" it
  Prompt injection -> tool call   -> the model is an untrusted planner: authorize each tool call with the
                                     END USER's permissions, not the agent's broad service account
  Confused deputy                 -> agent holds powerful creds; attacker text steers it to use them
  Sensitive actions               -> allow-list of tools, argument validation, confirmation for
                                     destructive or costly actions, dry-run modes, idempotency keys
  Audit                           -> log who, which tenant, which tool, arguments, model, request id

  Tool call path:   user token --> API gateway --> agent --> tool service
                     tool service re-validates the (delegated) user identity and scope; it does not trust
                     "the agent said so". Prefer short-lived, narrowly scoped tokens minted per task.

An LLM endpoint is an authorization problem twice: once at the front door (who may call, on which model, how much) and once behind the model (what the model's output is allowed to trigger). At the front door the important additions to normal API security are per-tenant token quotas and spend caps, because a leaked key is an unbounded bill, and keeping provider keys strictly server-side, which matters especially for a mobile developer: an API key embedded in an Android app can be extracted, so the app should call your backend, which authenticates the user and holds the provider key. See rate limiting, the LLM API gateway design and multi-tenant serving.

Behind the model, treat model output as untrusted input. Prompt injection, especially indirect injection through a retrieved web page, email or document, is unsolved as a class: no filter reliably separates instructions from data, so the design principle is to limit what a successful injection can do. Give tools the least privilege, run them with the requesting user's identity so the model cannot exceed what the user could do anyway, require confirmation for irreversible actions, validate arguments like any other untrusted input, and never put secrets in the prompt. For retrieval, the access-control check must happen in the query (metadata filter on tenant and permissions), otherwise a similarity search will happily return another tenant's chunk. Guardrails help but are one layer of defence in depth, not a guarantee; see guardrails, agentic systems and tool use, and RAG.

Key Pointers

How It Works

  1. The client authenticates: a password or SSO login for a user, an OAuth Authorization Code + PKCE exchange for an app, or an API key or Client Credentials grant for a service.
  2. The issuer returns a credential: an opaque session id, a signed JWT access token (plus a rotated refresh token), or a hashed-at-rest API key, each with an expiry policy and scopes.
  3. The client sends it on every request, typically as Authorization: Bearer ... or a Secure, HttpOnly, SameSite cookie, over TLS only.
  4. The gateway or service validates it: session lookup, or JWT checks on algorithm, signature, exp, iss, aud, or a hash lookup for a key; then it builds a verified identity (subject, tenant, scopes) and discards anything the client claimed about itself.
  5. The authorization layer decides: role and scope check, then attribute checks (tenant match, object ownership, model or plan entitlement, remaining quota), and only then the handler runs; the decision and identity are logged with the request id.
  6. When the access token expires the client uses the refresh token to get a new pair (rotating the refresh token and detecting reuse); logout or compromise revokes the refresh token family, session entry, or key.

Try It Yourself

A complete HS256 JWT signer and verifier using only hmac, hashlib and base64. It shows a valid token, expiry, payload tampering, the alg=none attack, a wrong key and a wrong audience being rejected:

import base64, hashlib, hmac, json

def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def b64u_dec(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def sign(claims, key, header=None):
    header = header or {"alg": "HS256", "typ": "JWT"}
    h = b64u(json.dumps(header, separators=(",", ":")).encode())
    p = b64u(json.dumps(claims, separators=(",", ":")).encode())
    sig = hmac.new(key, f"{h}.{p}".encode(), hashlib.sha256).digest()
    return f"{h}.{p}.{b64u(sig)}"

def verify(token, key, now, aud="llm-api", leeway=0):
    try:
        h, p, s = token.split(".")
        header = json.loads(b64u_dec(h))
        if header.get("alg") != "HS256":                      # never trust the token's own alg choice
            return "REJECT: unexpected alg %r" % header.get("alg")
        expected = hmac.new(key, f"{h}.{p}".encode(), hashlib.sha256).digest()
        if not hmac.compare_digest(expected, b64u_dec(s)):    # constant-time compare
            return "REJECT: bad signature"
        c = json.loads(b64u_dec(p))
    except Exception as e:
        return f"REJECT: malformed ({type(e).__name__})"
    if now > c["exp"] + leeway: return "REJECT: expired"
    if c.get("aud") != aud:     return "REJECT: wrong audience"
    return f"OK: sub={c['sub']} tenant={c['tenant']} scope={c['scope']}"

KEY = b"demo-secret-not-for-production"
now = 1_700_000_000
claims = {"sub": "user_42", "tenant": "acme", "scope": "chat:read", "aud": "llm-api",
          "iat": now, "exp": now + 900}                       # 15-minute access token
tok = sign(claims, KEY)
print("token parts:", [len(x) for x in tok.split(".")], "chars")
print("payload    :", b64u_dec(tok.split(".")[1]).decode())
print("1 valid            ->", verify(tok, KEY, now + 60))
print("2 expired          ->", verify(tok, KEY, now + 901))

# Tamper: attacker edits the payload (scope + tenant) but cannot re-sign without the key
h, p, s = tok.split(".")
evil = dict(claims, scope="admin:all", tenant="globex")
tampered = f"{h}.{b64u(json.dumps(evil, separators=(',', ':')).encode())}.{s}"
print("3 payload tampered ->", verify(tampered, KEY, now + 60))

# alg=none attack: unsigned token
none_h = b64u(json.dumps({"alg": "none", "typ": "JWT"}).encode())
print("4 alg=none         ->", verify(f"{none_h}.{p}.", KEY, now + 60))

# Wrong key (e.g. token minted by another environment)
print("5 signed, wrong key->", verify(sign(claims, b"other-key"), KEY, now + 60))
print("6 wrong audience   ->", verify(sign(dict(claims, aud="billing-api"), KEY), KEY, now + 60))
# Real output from python3:
# token parts: [36, 138, 43] chars
# payload    : {"sub":"user_42","tenant":"acme","scope":"chat:read","aud":"llm-api","iat":1700000000,"exp":1700000900}
# 1 valid            -> OK: sub=user_42 tenant=acme scope=chat:read
# 2 expired          -> REJECT: expired
# 3 payload tampered -> REJECT: bad signature
# 4 alg=none         -> REJECT: unexpected alg 'none'
# 5 signed, wrong key-> REJECT: bad signature
# 6 wrong audience   -> REJECT: wrong audience

Things to try: change tenant in the tampered payload but keep the original signature (rejected because the signature covers the payload), swap hmac.compare_digest for == (functionally the same here, but not timing-safe), and add a jti denylist to model revocation before expiry. The API-key and RBAC/ABAC demo is in the multi-tenant section above.

Interview Q&A

Q: Sessions vs JWT: which would you choose?

A: For a first-party browser app with one backend I would default to server-side sessions in a cookie, because logout and revocation are immediate and there is no token-parsing attack surface. For microservices, mobile clients or a gateway fronting many services I would use short-lived JWT access tokens (around 5 to 15 minutes) so services verify locally, plus a revocable refresh token. The deciding trade-off is revocation control versus per-request state, not which is more modern.

Q: How do you log out or revoke a JWT before it expires?

A: You cannot un-sign it, so you add state or shorten the window. Options: keep access tokens short and revoke the refresh token, keep a denylist of token ids that only needs entries until their exp, or compare a per-user token version claim against a cached value. Each option reintroduces a lookup, which is the price of early revocation; a small denylist in Redis is generally cheap because the entry count is bounded by revocation rate times token lifetime.

Q: Explain the OAuth Authorization Code flow with PKCE.

A: The app creates a random code verifier and sends its SHA-256 hash (the challenge) when redirecting the user to the identity provider. After login and consent the provider redirects back with a one-time code, and the app exchanges the code plus the original verifier at the token endpoint for tokens. If an attacker intercepts the code they do not have the verifier, so the exchange fails, which is why PKCE lets public clients such as mobile apps and SPAs do this without a client secret.

Q: OAuth vs OIDC?

A: OAuth 2.0 is about delegated authorization: a client obtains an access token with scopes to call an API. It does not define how to learn who the user is. OpenID Connect layers identity on it by adding an ID token (a JWT about the authentication event), a standard discovery document and a userinfo endpoint. The access token is for the API; the ID token is for the client, and mixing them up is a common mistake.

Q: API keys or OAuth for a developer-facing model API?

A: API keys for server-to-server calls, because they are simple and developers expect them, provided I generate them randomly, show them once, store only a hash, allow rotation with overlapping keys, scope them to a project, and attach per-key quotas. If end users grant third-party apps access to their data, or actions must be attributed to a user with consent and expiry, I add OAuth. The risk with keys is that they are long-lived bearer secrets, so leak detection and fast revocation matter.

Q: RBAC vs ABAC?

A: RBAC maps users to roles to permissions; it is easy to explain, audit and implement, but it explodes into many roles once rules depend on ownership, team, plan or context. ABAC evaluates policies over attributes of the subject, resource, action and environment, which is expressive but harder to test and reason about. In practice I use roles for coarse capability and add attribute checks for tenant, ownership and entitlements, and a relationship model when sharing and inheritance dominate.

Q: How do you prevent one tenant from seeing another tenant's data?

A: Derive the tenant id only from the verified credential, never from the request body or path, and make every data access tenant-scoped by construction, for example row-level security or a data-access layer that requires a tenant argument. Check object ownership on every read and write to defeat guessed ids, and add automated tests that call each endpoint with another tenant's token. For vector search, put the tenant and permission filter into the query itself.

Q: How would you secure an LLM API that agents call with tools?

A: At the front, authenticate the caller, enforce per-tenant token quotas and a hard spend cap, and keep provider keys server-side. For tools, treat the model as an untrusted planner: run each tool call with the end user's delegated identity and least privilege, validate arguments, require confirmation for destructive or expensive actions, and log every call. I also assume prompt injection will sometimes succeed, so I design so that a successful injection cannot exceed what the user could do and cannot reach other tenants' data.

Follow-up: A refresh token is stolen. How does rotation help, and where does it fail?

A: With rotation each use issues a new refresh token and invalidates the previous one; when the thief and the real client both try to use the same old token, the server sees a reused token and revokes the whole family, forcing re-login. It fails if the thief uses the token and the real client never does again (no reuse is observed until the legitimate client returns), and with concurrent refreshes from the same client that look like reuse, so you need a short grace window or careful client single-flighting. It limits the damage; it does not prevent the theft.

Follow-up: Why is an alg=none or HS/RS confusion attack possible, and how do you prevent it?

A: Because the header is attacker-controlled and naive libraries let it choose how verification happens: none means "no signature", and if a server accepts an RSA public key as an HMAC secret an attacker who knows the public key can forge tokens. The fix is for the verifier to fix the accepted algorithm and key for each issuer (never derive them from the token), use a maintained library with those defaults, and select keys only by a trusted kid lookup in your own JWKS.

Follow-up: A RAG assistant answers with a passage from a document the user should not see. Where do you fix it?

A: At retrieval, not at the prompt. Store tenant and ACL metadata with each chunk, apply the calling user's identity as a filter in the vector query (or re-check permissions on the returned document ids before building the prompt), and cache keyed by identity or permission set so one user's cached answer is never served to another. Prompt instructions like "do not reveal restricted content" are not an access control: once a chunk is in the context window, the model can leak it.

System Design Tie-in

In a design for an LLM platform, place authentication at the edge: an API gateway validates the API key (hash lookup, cached) or the JWT (signature checked locally against a cached JWKS, so no per-request call to the identity provider) and forwards a verified identity (tenant, subject, scopes) to internal services, which trust the gateway over mTLS or a signed internal token rather than re-parsing external credentials. Authorization then combines a role or scope check with per-tenant entitlements (which models, which features) and quota accounting in a fast store such as Redis. As an illustrative estimate, assume 5,000 requests/s across 500 tenants: local JWT verification is CPU-only work per request and scales with gateway instances, while a session-store lookup would add 5,000 reads/s and a network hop to the critical path, which is fine for Redis but is one more dependency to keep available. Budget quotas in tokens per minute and a monthly spend ceiling per tenant, because request counts alone do not bound cost, and alert on anomalies. For agents, describe the delegated-identity path from user to tool, the least-privilege tool set, the confirmation step for destructive actions, and per-user permission filters on retrieval. Finish with audit logging (who, tenant, model, tool, request id) feeding tracing and observability, and secret management: provider keys in a secrets manager with rotation, never in client apps.

Trade-offs

Every credential design trades revocation speed against per-request cost and complexity. Sessions revoke instantly but require a shared, highly available store on the hot path; JWTs verify anywhere without state but stay valid until expiry, and short lifetimes and denylists claw back control at the cost of refresh traffic and state you were trying to avoid. API keys are the simplest for developers but are long-lived bearer secrets; OAuth adds consent, scoping and expiry but also flows, an identity provider dependency and more failure modes. RBAC is legible but coarse, ABAC and relationship models are precise but harder to test, debug and explain to auditors. Stronger AI-specific controls (per-user delegated tool identities, retrieval-time ACL filters, confirmations) add latency, engineering effort and friction for users, and none of them make prompt injection impossible, only less damaging. The wrong choice is usually mismatched to the threat: heavy OAuth machinery for a single internal script, or a single shared service account for an agent that touches many tenants. Pick the simplest mechanism that gives the revocation speed, blast-radius limit and audit trail the data actually requires, and layer defences rather than betting on one.