Identity, Authentication, and Access Management Questions
Designing and operating identity and access control systems. Covers authentication protocols and standards (OAuth, SAML, OIDC, MFA), authorization models (RBAC, ABAC), identity lifecycle and privilege management, IAM architecture and automation, and access control across cloud and on-premises environments. The 'who can do what' control plane, distinct from cryptographic key management.
Design a continuous or adaptive authorization system that adjusts session trust in real time using signals such as device posture, IP reputation, behavioral anomalies, and recent authentication events. Explain how to represent and propagate trust level to microservices, how services should enforce it, and storage/caching strategies to keep decisions timely while auditable.
Sample Answer
Direct answer
Adaptive, continuous authorization computes a trust level, a tiered or continuous score rather than a single valid-or-invalid flag, from real-time signals: device posture (is the device compliant and up to date), IP reputation (is the source network associated with abuse), behavioral anomalies (does this request pattern match the user's own history), and the recency of authentication events (how long ago, and how strongly, did the user last prove who they are). That trust level is propagated to every microservice so each can make its own proportionate decision, for example allowing a read while requiring step-up authentication before a sensitive write, rather than one global allow-or-deny gate applied uniformly to the whole session.
Structured elaboration
Representing and propagating trust level to microservices. Represent trust as a small number of named tiers (say, high, medium, low) rather than a raw numeric score, carried as a signed claim alongside the access token, together with the individual signal values that produced that tier: device compliance status, an IP-reputation category, and minutes since the last strong authentication. The tiers give services a simple decision surface ("allow at medium or higher"), while the underlying signals let a service apply its own finer policy where the standard tiers do not fit its risk tolerance, such as a payments service requiring high trust specifically combined with multi-factor authentication (MFA) completed in the last five minutes. Because trust can change within a session's lifetime (a periodic device-posture check can flip from compliant to non-compliant; a threat-intelligence feed can flag the current network mid-session), it cannot simply be baked into the token at issuance and left static. It needs its own continuous-evaluation propagation path, similar in shape to the event-driven mechanism used for outright revocation elsewhere in identity systems, but carrying a graded score rather than a binary event.
How services should enforce it. Each microservice's own policy defines what trust tier, or specific signal combination, a given action requires, rather than one blanket threshold applied to the whole system. Low-risk actions may proceed at low or medium trust; high-risk actions require high trust and often an explicit, recent step-up authentication, not merely an inferred high score from ambient signals. This is risk-proportionate enforcement: the same session, at the same moment, can be permitted to do one thing and required to step up before doing another, because the trust requirement belongs to the action, not to the session as a whole. When enforcement rejects an action for insufficient trust, it should return a specific, actionable "step up required" response the client can act on, such as re-prompting for MFA, rather than a generic denial, so a legitimate user whose trust genuinely dipped (new network, aging authentication) has a clean path back rather than a confusing lockout.
Storage and caching strategies to keep decisions timely while auditable. Timeliness and auditability pull in different directions and need different storage. The low-latency path wants a small, fast cache of each session's current trust tier at the edge, updated by a central trust engine so individual microservices never each re-derive trust from raw signal sources on every request, which would be both slow (each service paying its own external-lookup cost) and inconsistent (two services could compute slightly different trust for the same session if the underlying signals changed between their two independent evaluations). The audit requirement wants the opposite: a durable, append-only, timestamped history of every trust-level change and which specific signal drove it, since a later investigation needs to reconstruct what the trust level actually was at a past moment, not only what it is now. Reconcile the two from a single update event: write the fast current-value cache and append to the durable history log from the same trust-recomputation event, so the audit write can lag by a small, bounded amount without ever blocking the real-time enforcement path, since the audit record is inherently retrospective rather than part of the live decision.
flowchart LR
Device[Device posture signal]
IP[IP reputation signal]
Behavior[Behavioral anomaly signal]
Auth[Recency of authentication signal]
Engine[Trust engine: recomputes tier from current signals]
EdgeCache[(Edge cache: current trust tier per session)]
History[(Append-only trust history log)]
SvcA[Service: view dashboard, low bar]
SvcB[Service: transfer funds, high bar + recent MFA]
Device --> Engine
IP --> Engine
Behavior --> Engine
Auth --> Engine
Engine -->|push update| EdgeCache
Engine -->|append change record| History
EdgeCache --> SvcA
EdgeCache --> SvcB
Worked example
A user logs in from their usual laptop and receives trust_level: high (compliant device, recognized network, MFA completed two minutes ago). That tier is written to the edge cache, keyed by session, and appended to the durable trust-history log with a timestamp.
Twenty minutes later, mid-session, the user's apparent network address changes to one a threat-intelligence feed flags as a VPN (virtual private network) exit node recently associated with account-takeover attempts elsewhere. The trust engine recomputes the session's tier down to low, pushes the update to the edge cache, and appends a history entry: {session_id, timestamp: 14:32:00, old_tier: "high", new_tier: "low", driving_signal: "ip_reputation_flag"}.
At 14:33, still under low trust, the same session requests view_dashboard, a low-risk action that service's own policy permits at low trust or above, so it succeeds without interruption; the design's proportionality means one negative signal does not lock the user out of everything. Shortly after, the session attempts transfer_funds, whose policy requires high trust combined with MFA within the last five minutes; enforcement reads the current cached tier (low, from 14:32) and rejects with a step-up-required response rather than a generic denial. The user completes a fresh MFA challenge; the trust engine recomputes from the full current signal set, not by simply overwriting the one signal that just changed, so if the flagged network is still in use, the recomputed tier may land at medium rather than snapping straight back to high, and the transfer proceeds only once the recomputed tier actually clears the service's specific bar.
Weeks later, an unrelated fraud review can query this session's trust-history log and reconstruct exactly when and why trust dropped, and how enforcement responded at that moment, entirely from the durable record, independent of whatever the session's trust level happens to be by the time anyone looks.
Trade-offs and pitfalls
- Collapsing this design back into one global trust threshold for the whole system defeats its entire purpose. A single blanket bar either over-restricts low-risk actions, annoying users with step-up prompts for a public dashboard, or under-protects high-risk ones, letting a borderline-trust session complete a sensitive transfer; the value is specifically in per-action, risk-proportionate thresholds.
- Letting each microservice independently re-derive trust from raw signal sources multiplies both latency and inconsistency. Every service pays its own external-lookup cost, and two services can legitimately compute different trust levels for the same session at the same moment if signals shifted between their separate evaluations; a shared, centrally-computed and cached assessment avoids both problems.
- Keeping only the current trust value, with no history, makes the exact investigation this system exists to support impossible: whether a specific access was appropriately gated at the time it happened. The append-only history is not a nice-to-have; it is what actually makes the design auditable, which the requirement explicitly asks for, not merely fast.
- Treating a successful step-up as fully restoring trust, rather than recomputing from the complete current signal set, can paper over a still-active negative signal, such as a still-suspicious network, simply because the user proved one different positive signal. Trust should be recomputed from everything currently known, not patched by overwriting just the signal that most recently changed.
- Returning a generic denial on a trust drop, instead of a specific step-up-required response, turns an ordinary, innocent situation (new network, aging authentication) into a confusing dead end, and tends to push users toward workarounds, such as disabling security features, rather than toward the clean recovery path the design is meant to offer.
Discuss differences between symmetric (HS256) and asymmetric (RS256) JWT signing algorithms. Create a migration plan to move from HS256 to RS256 across many services: key generation, distribution, library updates, handling tokens signed with old keys, preventing algorithm-confusion attacks, and operationalizing kid-based key rotation.
Sample Answer
Direct answer
HS256 (HMAC-SHA256, a symmetric algorithm where the signer and every verifier hold the same secret) and RS256 (RSA signature with SHA-256, an asymmetric algorithm where a private key signs and a public key verifies) differ in exactly one consequential way: with HS256 every verifying service holds a secret that could also forge a token, while with RS256 only the identity provider can sign, and every other service just verifies. Migrate as a phased, dual-running rollout, never a single flag flip: generate and publish the new key, teach every verifier to accept both algorithms keyed by a kid (key ID), cut the issuer over to RS256, wait out the longest token lifetime still in circulation, then retire HS256 entirely.
Structured elaboration
Differences, concretely. In an HS256 world with N independently-operated verifying services, the shared secret exists in N places, meaning N places it can leak from, and every one of those N services technically has the power to mint tokens as if it were the identity provider. RS256 confines signing power to exactly the identity provider; every verifier only ever needs the safely-public public key.
Phase 0: preparation. Generate the RSA key pair (2048-bit minimum, 3072-bit for longer shelf life) inside an HSM (hardware security module) or a cloud KMS (key management service), never as a raw private-key file emailed or copied around. Assign it a kid distinct from anything currently in use.
Phase 1: dual-verification rollout. Update every verifying service's JWT (JSON Web Token) library or middleware so it selects the verification key and algorithm by kid, from an explicit allow-list, rather than trusting the token's own alg claim. Point HS256 verification at the existing shared secret (wherever it's currently stored) and RS256 verification at the new public key, fetched from a new JWKS (JSON Web Key Set) endpoint you stand up as part of this phase. Ship this to every verifying service and confirm both paths work (for example with a canary token of each type) before moving on. Nothing externally visible changes yet: the issuer is still only signing HS256 tokens. This phase is the largest engineering lift, because it touches every independently-deployed verifying service, but it carries zero user-facing risk because no new algorithm is in production use yet.
Phase 2: cut over the issuer. Switch the token-issuing service to sign new tokens with RS256, tagged with the new kid. Tokens already signed with HS256 remain valid, because Phase 1's verifiers still accept them.
Phase 3: sunset window. Wait out the maximum lifetime of any token type still being verified. This is bounded by whichever token type lives longest in your system, typically refresh tokens rather than short-lived access tokens, so the true sunset window is set by the long pole, not the average case. Monitor verification logs for alg: HS256 still occurring; once it drops to zero (or an acceptable floor, accounting for long-lived tokens belonging to sessions that may simply never return), proceed.
Phase 4: retire HS256. Remove HS256 acceptance from every verifying service in a second deploy cycle, and destroy the shared HMAC secret wherever it was stored, so it is useless even if it leaks later.
flowchart LR
P0[Phase 0: generate RSA key pair, assign kid] --> P1[Phase 1: dual verification, verifiers accept HS256 and RS256]
P1 --> P2[Phase 2: issuer cuts over to signing RS256]
P2 --> P3[Phase 3: sunset window, wait out max token lifetime]
P3 --> P4[Phase 4: retire HS256, destroy shared secret]
Distribution. Rather than manually pushing the new public key into each service's configuration (which doesn't scale and drifts), publish it at a JWKS endpoint; verifiers fetch and cache it with a reasonable TTL (time-to-live), re-fetching immediately if they ever see an unrecognized kid.
Library updates. Most mainstream JWT libraries already support RS256 out of the box, so the real work is rarely "does the library support this algorithm." It's fixing how the library is configured: making sure verification is pinned to an explicit algorithm allow-list, selected by kid, instead of trusting whatever the incoming token claims about itself. Teams migrating off HS256 very often discover their existing verification code had exactly this bug (trusting the token's own alg), which brings us to the next point.
Preventing algorithm-confusion attacks. The canonical version of this attack: a verifier calls something like "verify this token using whatever algorithm its header says," an attacker submits a token with alg: HS256 and a signature computed using the RSA public key as if it were an HMAC secret. Since the public key is, by definition, not secret, the attacker can compute a valid-looking HMAC with it, and a verifier that blindly follows the token's own alg claim accepts the forgery. The fix: the verifier's algorithm allow-list is fixed in its own configuration, never taken from the token. During the dual-acceptance window the allow-list is {HS256, RS256}, but which specific key is used to check a given token is driven by kid, a known reference to a known key of a known type, never by blindly trusting the claimed algorithm.
Operationalizing kid-based key rotation. Track every key, including the legacy HMAC secret (give it an explicit id too, even if it's just a label like legacy-hmac-v1), in a small key registry with a status: pending, active-signing, active-verify-only, retired. Build (or reuse) automation that can generate a new key, publish it to JWKS in verify-only status, flip the issuer to sign with it after a soak period, and retire the old key after the token-lifetime window passes. This is exactly the machinery every future rotation, whether routine or an emergency compromise response, reuses, so building it once here pays off on every subsequent rotation.
Worked example
Suppose access tokens live 1 hour and refresh tokens live 30 days, across 20 verifying services (both numbers are given assumptions for this walkthrough, not measurements). Day 0: dual-verification (Phase 1) is deployed everywhere; both algorithms are now accepted. Day 1: the issuer (Phase 2) cuts over to signing only RS256; a refresh token minted at this exact moment could still be HS256 if it slipped in just before cutover, and it carries a 30-day lifetime from its issuance date. The latest possible HS256-signed refresh token is therefore valid until Day 1 + 30 days = Day 31. So the sunset window (Phase 3) must run at least until Day 31, not until the 1-hour access-token lifetime suggests, because the refresh token is the long pole. Only at Day 31 or later is it safe to retire HS256 (Phase 4) and destroy the shared secret, since by then every token that could possibly have been signed under it has expired.
Trade-offs and pitfalls
Skipping the dual-verification phase and flipping the issuer straight to RS256 breaks every live session instantly, since no verifier can check the new signatures yet.
Trusting the token's own alg claim (the root cause of algorithm-confusion attacks) is the single most common bug this migration should catch and fix, not just work around.
Deleting the shared secret or the old public key before every possible outstanding token, including long-lived refresh or "remember me" tokens, has actually expired causes a wave of legitimate "invalid signature" failures.
Treating "our library already supports RS256" as sufficient understates the work: the real lift is the trust and configuration wiring (kid-based key selection, an explicit algorithm allow-list), which a library version bump does not do for you.
Doing this migration as a one-off manual project, rather than building the small rotation-automation described above, means the next rotation, whether routine or an emergency compromise response, starts from scratch instead of reusing tooling that already exists.
You are the lead security engineer responsible for migrating 200 services from local username/password auth to OIDC SSO. Draft a phased migration plan covering discovery of affected systems, gating criteria for rollout phases, developer onboarding, handling legacy clients, session migration, rollback strategy, and metrics/KPIs to determine successful migration.
Sample Answer
Direct answer
A 200-service local-auth-to-OIDC (OpenID Connect) migration succeeds or fails on sequencing and gating, not on the OIDC integration itself, which is well understood. The plan starts with an automated discovery pass to build the actual inventory, migrates in cohorts gated by objective criteria rather than a calendar date, and treats legacy clients that genuinely cannot be touched as a permanent, owned exception category with a compensating control, not a blocker to the other 199 services. Rollback must be a rehearsed, per-cohort procedure, not a whole-program undo, and the metrics that actually indicate success track authentication failure rate and support-ticket volume per cohort, not "percentage of services migrated" alone, since a service can be technically migrated and still be failing silently for real users.
Structured elaboration
Discovery of affected systems
- An accurate inventory of 200 services rarely already exists. Build it by combining an automated scan (searching each service's codebase and configuration for local-auth library usage, or a local users/passwords database table) with a manual attestation pass where each service owner confirms or corrects the automated finding, since automated scans reliably miss non-standard implementations and manual-only surveys reliably miss services nobody remembers still exist.
- Classify each discovered service by migration complexity, not just "does it use local auth": does it have an active maintaining team, does it fit the standard web-request auth pattern the new OIDC integration expects, does it have exotic requirements (a legacy client-credential grant that doesn't map cleanly to OIDC's user-facing flows). This classification is exactly what the rollout-phase gating below sequences on.
Gating criteria for rollout phases
- Sequence cohorts by complexity and blast radius, not alphabetically or by convenience: phase 1 is a small number of low-risk, actively maintained, standard-pattern services, validating the integration itself against real production traffic at low stakes; phase 2 is the bulk of standard-pattern services; a final phase handles the exotic and legacy cases identified during discovery.
- Gate advancement to the next phase on objective criteria measured from the previous phase, not a calendar date: authentication error rate for migrated services staying within an agreed band of the pre-migration baseline, support-ticket volume attributable to auth not exceeding an agreed threshold, and zero unresolved rollback incidents from the current phase, all before the next cohort begins.
Developer onboarding
- Provide each service team a reference implementation, a working, minimal example of the OIDC integration pattern in the organization's dominant language or framework, and a self-service migration checklist, rather than requiring the central team to hand-hold all 200 migrations; the central team's actual job is reviewing and unblocking, not implementing each one.
- Run office hours or a dedicated support channel scoped to each phase specifically, so a team hitting an integration problem gets fast help exactly when they need it, during their own migration window, not a generic, perpetually available channel that's easy to deprioritize.
Handling legacy clients
- A client that genuinely cannot be modified, an unmaintained service, a third-party appliance with hardcoded local-auth expectations, needs a documented, permanent exception category with a compensating control, for example a protocol-translation proxy that accepts OIDC tokens externally and translates to whatever the legacy client's internal auth expects, isolating the limitation behind a boundary the rest of the migration doesn't have to work around.
- Track exceptions explicitly as a named, owned risk with a periodic re-review, not silently left as "still local auth, will get to it eventually", since an unowned exception is how a supposedly temporary gap becomes a permanent, unmonitored one.
Session migration
- For a service transitioning from local sessions to OIDC-issued tokens, support both simultaneously for a transition window: an already-logged-in user's local session keeps working until it naturally expires, while a fresh login goes through OIDC, rather than forcibly terminating every existing session the moment a service cuts over, which would force a mass simultaneous re-authentication event across the affected user population.
- Communicate the transition window's own expiry to affected users in advance, so the eventual forced re-authentication, once local sessions are fully retired, is expected rather than a surprise support spike.
Rollback strategy
- Rollback must be scoped and rehearsed per cohort, not as a whole-program undo: each phase's migration should be revertible independently, for example feature-flagging the auth path per service rather than a single global switch, so a problem discovered in phase 2 doesn't require unwinding phase 1's already-stable services.
- Rehearse the rollback procedure itself before it's needed, a planned drill on a non-production or canary service, since a rollback procedure that's only ever existed on paper is the procedure most likely to fail under the actual pressure of a real incident.
Metrics and KPIs for successful migration
- Track per cohort, not just in aggregate: authentication failure rate against baseline, support-ticket volume attributable to auth, time-to-resolution for auth-related incidents during the transition window, and the percentage of a cohort's user sessions that have naturally transitioned to OIDC, not just "service marked as migrated" in a tracker.
- Treat "percentage of services migrated" as a status metric, not a success metric; a service can be technically cut over and still failing silently for a subset of its users, a legacy client that never got the exception treatment it needed. The real success signal is the auth-failure and support-ticket metrics staying within their agreed bands, not a checklist being checked off.
Worked example
Concrete cohort sizing and a gating-criteria illustration for 200 services. The specific numbers below are illustrative POLICY CHOICES a program would set and tune for its own risk tolerance, not measured outcomes:
- Phase 0 (discovery): touches all 200 services, changes nothing in production.
- Phase 1 (pilot): a deliberately small cohort, for example 10 services (5% of the fleet), chosen for low risk and an actively engaged owning team, validating the integration pattern itself against real traffic before wider rollout.
- Phase 2 (bulk): the majority of the remaining roughly 180 services that the complexity classification did not flag as exotic.
- Phase 3 (remainder): the exotic and legacy clients requiring the proxy-based exception pattern described above.
An example gate from Phase 1 to Phase 2: authentication error rate for the pilot cohort must stay within an agreed multiple of its pre-migration baseline (for instance, no more than 1.5 times baseline) sustained for a full week, and there must be zero unresolved rollback incidents from the pilot. The discipline that matters is having an explicit, objective threshold at all, evaluated against real pilot data before advancing, not any particular multiple or duration; an organization with a lower risk tolerance would simply set a stricter number and hold the same discipline.
Trade-offs and pitfalls
- A purely calendar-driven rollout, finish by end of quarter, creates pressure to advance cohorts before the previous phase's own gating criteria genuinely clear, exactly how a migration ships a phase 2 problem that phase 1's own gate should have caught. The gating criteria only work if the organization is willing to actually hold a phase when its own thresholds aren't met, not treat them as advisory.
- Supporting local sessions and OIDC simultaneously during the transition window is the correct way to avoid a mass forced re-authentication, but it means running two authentication code paths in parallel for a period, real complexity and a real, if temporary, expanded attack surface that must have its own defined end date, not linger indefinitely because retiring the old path never gets prioritized once the new one works.
- Treating unmaintained legacy clients as a permanent exception category is honest, but a compensating-control proxy in front of one is itself new infrastructure someone now owns; if that ownership isn't assigned explicitly, the proxy becomes exactly the kind of unowned, unmonitored gap the original local-auth service was.
- "Percentage of services migrated" is an easy, visible metric to report upward, which is exactly why it's tempting to over-index on it even though it says almost nothing about whether the migration is succeeding for real users; report it alongside the auth-failure and ticket-volume metrics, never in place of them.
Design a hybrid approach that allows near-instant revocation of stateless JWTs (issued with 2-hour TTL) for a global user base. Discuss options like token introspection, short TTLs, revocation blacklists, distributed caches, pub/sub invalidation, and the cost/performance trade-offs for each. Recommend a concrete architecture and justify it.
Sample Answer
Direct answer
No single option (token introspection, short TTLs (time-to-live, how long a token stays valid), a revocation blacklist, or a distributed cache) makes a stateless JWT (JSON Web Token) revocable instantly and cheaply on its own; each trades away either the "no network call" property that made JWTs attractive in the first place, or the promptness of the revocation. The workable answer is a hybrid: keep verifying the JWT's signature locally for speed, add a fast, small, false-positive-tolerant local pre-check (a Bloom filter of recently revoked token IDs) that almost always says "definitely not revoked" for free, and fall back to an authoritative store only for the rare token the pre-check flags as possibly revoked, with revocation events fanned out to every service instantly over pub/sub.
Structured elaboration
| Option | How it works | Cost / performance trade-off |
|---|---|---|
| Token introspection | Every request calls the authorization server (or a shared store) to check validity | Fully authoritative and instantly consistent, but adds a network round trip to every single request, which does not scale to a fleet of independently-scaling microservices verifying millions of requests per second |
| Short TTLs alone | Shrink the token lifetime so a revoked-but-not-yet-caught token expires quickly on its own | Bounds the damage window without any extra infrastructure, but does not give "near-instant" revocation; a 2-hour TTL (as given) still leaves up to 2 hours of usable access after revocation with no other mechanism |
| Revocation blacklist (a plain list of revoked token IDs, checked exactly) | Every service checks the presented token's ID against a shared list of revoked IDs | Exactly correct (no false positives), but the list itself is a single shared store every verification now depends on, and it grows without bound unless pruned by token expiry |
| Distributed cache | Replicate the revoked-ID set (or the blacklist) into a fast in-memory cache near each service | Removes most of the network latency of a remote lookup, but still costs memory proportional to the number of outstanding revocations, and a cache miss still needs to fall back to something authoritative |
| Pub/sub invalidation | Broadcast each revocation event to every interested service the instant it happens | Near-instant propagation with a single event per revocation (not a poll), but delivery is not guaranteed against a dropped connection or a service that was briefly down, so it needs a fallback path for what it misses |
The recommended hybrid architecture:
flowchart LR
Client[Client request + JWT] --> Svc[Microservice: verify JWT signature + local Bloom filter check]
Svc -- jti possibly revoked --> Store[(Redis: authoritative revoked-jti set)]
Store -- confirmed revoked --> Svc
Svc -- jti not in filter --> Allow[Request proceeds]
Publisher[Logout / admin revoke event] --> Store
Store --> Bus[[Pub/Sub: revoked jti]]
Bus --> Svc
Rebuild[Periodic filter-rebuild job] --> Store
Rebuild --> Broadcast[[Broadcast refreshed Bloom filter]]
Broadcast --> Svc
Each service verifies the JWT's signature locally (no network call, the fast path that handles the overwhelming majority of requests), then checks the token's jti (JWT ID, a unique identifier claim) against a small in-memory Bloom filter of recently revoked IDs. A Bloom filter can have false positives (occasionally flagging a valid token as "maybe revoked") but never false negatives (a truly revoked token is always flagged), which is exactly the asymmetry this problem needs: the rare false positive costs one extra authoritative lookup against Redis, while a false negative would mean a revoked token silently still working, which is not acceptable. Revocation events (logout, admin action, a compromised-token report) write to the authoritative Redis store and are pushed over pub/sub to every service instantly, so the filter reflects new revocations within the pub/sub propagation delay; a periodic rebuild job re-derives the Bloom filter from the authoritative store as a fallback for any service that missed a pub/sub message (a dropped connection during a brief outage), bounding the worst case to the rebuild interval rather than leaving a permanent gap.
The authorization-decision-caching variant. The same propagation mechanism (write to an authoritative store, then push an invalidation event over pub/sub, with a periodic rebuild as the fallback for anything the fan-out missed) generalizes beyond pure token validity: a service that caches full authorization decisions ("can user X do Y on resource Z") rather than just "is this token still valid" needs the identical pattern to keep those cached decisions from going stale after a permission changes, just with the cache holding decision results instead of a revoked-ID Bloom filter. The freshness guarantee is the same shape either way: instant in the common case via the event, bounded by the rebuild/refresh interval in the worst case.
Worked example
Scenario (explicitly pinned, not measured production data): a global user base of 300 million registered accounts, with 20 million peak concurrent sessions. Assume 0.5% of active sessions generate a revocation event per hour (logout, password change, admin action); with the given 2-hour access-token TTL, the system must keep at most 2 hours' worth of revocations "hot" in the pre-check at any time.
import hashlib
import math
import random
random.seed(42)
DAU_active_sessions = 20_000_000
revocation_rate_per_hour_fraction = 0.005
token_ttl_hours = 2
revocations_per_hour = DAU_active_sessions * revocation_rate_per_hour_fraction
N = int(revocations_per_hour * token_ttl_hours) # steady-state outstanding revoked-jti set
P_TARGET = 0.001 # target false-positive rate
M = math.ceil(-(N * math.log(P_TARGET)) / (math.log(2) ** 2)) # bit array size
K = round((M / N) * math.log(2)) # hash function count
class BloomFilter:
def __init__(self, m_bits, k_hashes):
self.m, self.k = m_bits, k_hashes
self.bits = bytearray((m_bits + 7) // 8)
def _positions(self, item):
h1 = int.from_bytes(hashlib.sha256(item.encode()).digest()[:8], "big")
h2 = int.from_bytes(hashlib.sha256((item + "salt").encode()).digest()[:8], "big")
for i in range(self.k):
yield (h1 + i * h2) % self.m
def add(self, item):
for pos in self._positions(item):
self.bits[pos // 8] |= (1 << (pos % 8))
def __contains__(self, item):
return all(self.bits[pos // 8] & (1 << (pos % 8)) for pos in self._positions(item))
bf = BloomFilter(M, K)
revoked_ids = [f"revoked-jti-{i}" for i in range(N)]
for rid in revoked_ids:
bf.add(rid)
all_found = all(rid in bf for rid in revoked_ids) # must be True: zero false negatives
valid_ids = [f"valid-jti-{i}" for i in range(1_000_000)]
false_positives = sum(1 for vid in valid_ids if vid in bf)
empirical_fp_rate = false_positives / len(valid_ids)
print(f"m={M:,} bits, k={K}, n={N:,}, target p={P_TARGET}")
print(f"all {N:,} revoked ids still detected (zero false negatives): {all_found}")
print(f"empirical false-positive rate over {len(valid_ids):,} valid ids: {empirical_fp_rate:.5f}")
print(f"filter size: {M/8/1024/1024:.2f} MB")
Output (actually run, unmodified):
m=2,875,518 bits, k=10, n=200,000, target p=0.001
all 200,000 revoked ids still detected (zero false negatives): True
empirical false-positive rate over 1,000,000 valid ids: 0.00102
filter size: 0.34 MB
At this scale, holding 200,000 outstanding revoked token IDs costs roughly 0.34 MB of memory per service instance, small enough to replicate to every instance in the fleet, and the empirically measured false-positive rate (0.00102) lands right at the targeted 0.001, meaning only about 1 in 1,000 valid-token checks ever pays the extra Redis round trip, while every actually-revoked token is caught with certainty.
Trade-offs and pitfalls
The hybrid design's core trade-off is tunable: a smaller false-positive target (P_TARGET) needs more bits per entry, trading memory for fewer unnecessary Redis round trips, and this is a knob to revisit as traffic and revocation-rate assumptions change, not a constant to set once. A common pitfall is forgetting the periodic-rebuild fallback and treating pub/sub as sufficient by itself; any message bus can drop a message during a network partition or a brief consumer outage, and without a rebuild path that gap becomes a silent, permanent hole rather than a bounded one. Another is sizing the filter for the wrong window: it only needs to hold revocations for the outstanding token lifetime (here, 2 hours), not for the system's entire history, since a token from before that window has already expired on its own and revoking it again is meaningless.
Describe a secure password storage scheme for a large-scale web application that will hold millions of user accounts. Explain in detail how you would store passwords to protect against offline cracking and database leaks. Cover algorithm selection (e.g., Argon2, bcrypt), per-user salts, optional peppers, cost parameters, migration strategy for legacy hashes, and operational practices (rate-limiting login attempts, monitoring, user experience trade-offs).
Sample Answer
Direct answer
For a large-scale application, passwords should never be stored in any reversible or fast-to-compute form; they should be hashed with a modern, deliberately slow password-hashing algorithm, Argon2id (the current OWASP-recommended default) or bcrypt as a widely-supported alternative, combined with a unique, randomly generated salt per user so identical passwords never produce identical stored values, and tuned cost parameters that make each individual guess expensive enough to make large-scale offline cracking impractical even after a full database leak. A pepper (a secret value held outside the database, in application configuration or a secrets manager) adds a second layer that survives a database-only leak. The remaining, equally important half of the design is operational: a migration path that can move users off a weaker legacy hash without forcing a mass password reset, rate limiting on login attempts, monitoring for credential-stuffing patterns, and being honest about the user experience cost of a slow, secure hash.
Structured elaboration
Algorithm selection. Argon2id is the current recommended default: it is a memory-hard function, meaning it deliberately requires a configurable amount of RAM per hash computation, not just CPU time, which specifically defeats the massively parallel cracking hardware (GPUs, ASICs) that made older, memory-light algorithms crackable at enormous scale. bcrypt is an older but still acceptable and extremely widely deployed alternative, CPU-hard but not memory-hard, and remains a reasonable choice where Argon2 tooling is not yet available in a given language ecosystem. Both are deliberately slow by design, which is the entire point: a login check that takes 100 to 300 milliseconds is imperceptible to a real user logging in once, but the same cost multiplied across billions of guess attempts makes offline brute-forcing of a leaked hash database computationally expensive rather than trivial. Fast general-purpose hashes (MD5, SHA-256 used alone) must never be used for password storage, since their entire design goal, being fast, is the opposite of what password storage needs.
Per-user salts. A salt is a random value generated uniquely per user and stored alongside the hash (it does not need to be secret). Its purpose is specifically to defeat precomputed lookup tables (rainbow tables) and to ensure that two users with the same password produce completely different stored hashes, so a leak does not reveal "these accounts share a password" as a free signal, and an attacker cannot precompute a single table of common-password hashes that works against every account in the database at once. Modern password-hashing libraries (Argon2 and bcrypt implementations) generate and store the salt automatically as part of the resulting hash string, so this is rarely something an implementation has to build by hand.
Optional peppers. Unlike a salt, a pepper is a secret value, typically a single application-wide secret (or one of a small rotating set) held outside the database entirely, in application configuration, an environment variable, or a secrets manager. Its purpose is to add protection specifically against a database-only leak: an attacker who exfiltrates the password table alone (via a SQL injection vulnerability, an exposed backup, a misconfigured replica) still lacks the pepper, and cannot verify or crack the hashes without it, whereas an attacker who compromises the application server itself (and can therefore read the pepper too) is not helped by it. A pepper is a defense-in-depth layer on top of a properly salted hash, not a replacement for one.
Cost parameters. Both Argon2 and bcrypt expose tunable cost parameters (Argon2: memory size, iteration count, and parallelism; bcrypt: a work factor controlling the number of internal rounds) that should be set as high as the application's actual login-latency budget and server capacity allow, not left at a library default that may be outdated. The right approach is periodic re-benchmarking against current hardware (as attacker hardware and defender server hardware both improve over time, a cost parameter set once at launch quietly becomes weaker relative to modern cracking capability) and choosing the highest cost setting that keeps login latency within an acceptable, typically sub-second, user-facing budget.
Migration strategy for legacy hashes. This is the senior wrinkle in an otherwise well-understood staple: an application that currently stores passwords with a weaker legacy scheme (an old bcrypt work factor, or worse, an unsalted fast hash from years ago) cannot simply re-hash every stored password at once, since the plaintext is, by design, never available after the original hash was stored. The standard approach is rehash-on-login: the first time a user successfully authenticates after the migration begins, verify their password against the legacy hash as before, and if it succeeds, immediately re-hash the now-known-correct plaintext with the new algorithm and cost parameters and replace the stored value, all inside that single authenticated request. Users who log in regularly migrate transparently and gradually; users who never log in again keep their weaker hash indefinitely, which is an acceptable, bounded residual risk (their credential was never actively re-exposed, it simply was not upgraded), rather than the disruptive alternative of forcing every user to reset their password immediately, which causes real support load and account-abandonment.
Operational practices. Password hashing strength alone does not address every risk: rate limiting on login attempts (per-account and per-source-IP, with backoff and lockout thresholds) is necessary because a strong hash slows offline cracking of a leaked database but does nothing to stop a live, online guessing attack against the login endpoint itself, which needs its own defense. Monitoring should track failed-login-attempt patterns (spikes against a single account, or the same password tried across many accounts, a strong credential-stuffing signal) and alert distinctly from ordinary user error. The user-experience trade-off of a deliberately slow hash is real but small at the individual-request scale (a few hundred milliseconds added to a login that happens infrequently per user) and should be weighed against server capacity at peak login volume (a cost parameter tuned for security alone, without regard to concurrent login volume during, say, a Monday-morning traffic spike, can itself become an availability problem, which is why benchmarking cost parameters against realistic peak load, not just security ideal, matters).
Worked example
The rehash-on-login migration pattern, shown structurally (illustrative, not an executed benchmark):
def verify_and_migrate(user, submitted_password):
if user.hash_algorithm == "legacy_sha256_unsalted":
if legacy_verify(submitted_password, user.stored_hash):
# Correct password now known in plaintext for this one request only;
# immediately re-hash with the current algorithm and discard the plaintext.
user.stored_hash = argon2id_hash(submitted_password)
user.hash_algorithm = "argon2id"
save(user)
return True
return False
elif user.hash_algorithm == "argon2id":
return argon2id_verify(submitted_password, user.stored_hash)
Trace this against a concrete population: assume 1,000,000 accounts, of which 600,000 log in at least once during the three months after migration begins. Each of those 600,000 accounts transparently upgrades to Argon2id the moment they authenticate, with no visible change to the user beyond the same login flow. The remaining 400,000 dormant accounts keep the legacy hash until they next log in (which may be never, if the account has been abandoned) or until a separate, explicit decision is made to force a reset for accounts that remain on the legacy scheme past some deadline. This is a deliberate, named trade-off: gradual, transparent migration for active users versus an unresolved tail of dormant accounts, rather than a single disruptive mass reset that would affect all 1,000,000 accounts' users regardless of whether their individual risk actually changed.
Trade-offs and pitfalls
- Choosing a fast general-purpose hash (unsalted or salted MD5/SHA-256) for password storage is the single most common and most damaging mistake; the entire design goal of a password hash is to be slow and memory-hard, the opposite of what those functions were built for.
- A pepper is not a substitute for proper salting and a slow algorithm; it defends against a narrower threat (database-only leak without application-server compromise) and should be treated as an additional layer, not the primary control.
- Cost parameters set once and never revisited quietly weaken over time as attacker hardware improves; periodic re-benchmarking against current hardware should be a scheduled operational task, not a one-time launch decision.
- A forced mass password reset after discovering a legacy-hash problem causes real, measurable user friction and support load, and the rehash-on-login pattern exists specifically to avoid that cost while still making steady, real progress on migrating the actual risk.
- Hashing strength does not substitute for rate limiting and monitoring; a perfectly chosen Argon2id configuration does nothing to stop a slow, patient, low-volume online guessing attack against the live login endpoint, which is a distinct threat requiring its own defense.
Unlock Full Question Bank
Get access to all Identity, Authentication, and Access Management interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.