System Design · Security

Design an Authentication & SSO System (OTP Login, OAuth, Sessions)

A complete design for logging users in and keeping them safely logged in: phone OTP that resists abuse, the sessions vs JWT decision, refresh tokens with reuse detection, single sign-on with OpenID Connect, MFA, and revocation that actually works.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 17 min read
A central shield and keyhole connected to many apps, representing authentication and single sign-on
TL;DR
  • OTP login is a rate-limiting and fraud problem disguised as a feature: hashed single-use codes, attempt caps, resend cooldowns, per-phone/IP/device limits, multiple SMS providers.
  • Use short-lived JWT access tokens (validated locally everywhere) plus server-side refresh tokens with rotation and reuse detection. You get stateless scale and real revocation.
  • Revocation = short TTLs + revoke refresh tokens + a small denylist or "valid after" timestamp checked at the gateway.
  • SSO = a central OpenID Connect identity provider with its own session; apps use the authorization code flow with PKCE.
  • MFA and step-up auth (TOTP, passkeys) protect sensitive actions, because SMS alone is weak against SIM swap.

Every product has a login screen, so "design authentication" sounds easy. In interviews it is a sharp question: it tests whether you understand the difference between proving identity and carrying it around, why tokens are hard to revoke, what can go wrong with OTPs at Indian scale (where phone-number login is the norm for apps like Swiggy, Zomato, PhonePe, Dream11 and Zepto), and how SSO actually works rather than "we use OAuth".

I will design one system that covers a consumer app (phone OTP, mobile and web) and enterprise-style SSO across multiple internal apps, since interviewers often combine the two. For the protocol background, OAuth 2.0, OIDC and JWT explained is a good companion.

Scope and Requirements

Functional

  • Sign up / log in with phone + OTP (primary), optionally email + password and "Sign in with Google".
  • Stay logged in on mobile for weeks; web sessions with sensible expiry.
  • Logout, logout from all devices, and a "your devices" list.
  • SSO: one login works across the company's apps (consumer app, seller portal, support tool), and internal tools via corporate identity.
  • MFA for sensitive accounts and step-up authentication for sensitive actions.
  • Admin: lock accounts, force logout, audit login history.

Non-functional

  • Token validation on every API request must add near-zero latency.
  • Login availability is critical; an auth outage is a full outage.
  • Resistant to brute force, credential stuffing, OTP abuse and token theft.
  • Revocation effective within seconds to a few minutes.
  • Compliant with local norms: in India, commercial SMS must use DLT-registered sender IDs and templates under TRAI rules, which affects OTP delivery setup.

Estimates

  • 100M registered users, 30M DAU.
  • Most users stay logged in. Assume 3M OTP logins/day (new devices, reinstalls, expired sessions) → ~35 OTP sends/s average, ~500/s at peak (sale launches, match start, marketing pushes).
  • OTP SMS cost is real money: at even a fraction of a rupee per SMS, millions of sends a day add up, and fraud that inflates sends is a direct financial loss.
  • Token validations: 30M DAU × ~200 API calls/day = 6B/day ≈ 70k/s average, ~300k/s at peak. This rules out a central database lookup per request and drives the JWT decision.
  • Refresh calls: one per access token lifetime per active device. With 15-minute access tokens and ~5M concurrently active devices at peak, ~5,500 refreshes/s. Comfortable for a Redis-backed or sharded-DB token store.

Architecture

Mobile / Web apps Other company apps (SSO) │ │ ▼ ▼ ┌────────────────────────────────────────────────────────┐ │ API Gateway: validate JWT locally (cached JWKS), │ │ check revocation cache, inject X-User-Id │ └───────┬───────────────────────────────┬────────────────┘ │ /auth/* │ business APIs ▼ ▼ ┌──────────────────────────┐ Microservices │ Auth service / IdP (OIDC)│ │ - OTP start/verify │──► OTP store (Redis, TTL) │ - token issuance │──► SMS router ──► SMS providers A/B/C │ - refresh + rotation │──► Refresh tokens / sessions (Redis + DB) │ - /authorize, /token │──► Users, devices, MFA secrets (SQL) │ - JWKS, discovery │──► Signing keys (KMS / HSM) │ - MFA, risk engine │──► Risk signals, audit log (Kafka) └──────────────────────────┘ ▲ │ federation (OIDC / SAML) Google sign-in, corporate IdP (Azure AD / Okta) for employees

The auth service is both the login backend for first-party apps and an OpenID Connect provider for SSO. Everything else in the company only needs to validate tokens it issues, which is a local signature check.

Deep Dive 1: Phone OTP Login

The flow

  1. POST /auth/otp/start {phone, device_id}: validate number format, run rate limits and risk checks, generate a 6-digit code with a CSPRNG, store its hash with a 5-minute TTL, send via SMS. Respond the same way whether or not the number is registered.
  2. Android apps can auto-read the code with the SMS Retriever API (the message includes an app hash), so users do not type it; iOS offers one-time-code autofill from Messages.
  3. POST /auth/otp/verify {phone, code, device_id}: increment the attempt counter, compare hashes in constant time, delete the code on success (single use), create or fetch the user, register the device, and issue tokens.
Python: OTP start/verify with Redis
import hashlib
import hmac
import secrets

PEPPER = b"load-from-secrets-manager"
OTP_TTL_S, MAX_ATTEMPTS, RESEND_COOLDOWN_S = 300, 5, 30

def _digest(phone: str, otp: str) -> str:
    return hmac.new(PEPPER, f"{phone}:{otp}".encode(), hashlib.sha256).hexdigest()

def send_otp(r, phone: str, sms) -> str:
    if not r.set(f"otp:cooldown:{phone}", 1, ex=RESEND_COOLDOWN_S, nx=True):
        return "TOO_SOON"
    if r.incr(f"otp:sends:{phone}") > 5:          # per-phone hourly cap
        return "LIMITED"
    r.expire(f"otp:sends:{phone}", 3600)
    otp = f"{secrets.randbelow(10**6):06d}"       # CSPRNG, not random.randint
    r.set(f"otp:code:{phone}", _digest(phone, otp), ex=OTP_TTL_S)
    r.delete(f"otp:attempts:{phone}")
    sms.send(phone, otp)
    return "SENT"

def verify_otp(r, phone: str, otp: str) -> str:
    stored = r.get(f"otp:code:{phone}")
    if stored is None:
        return "EXPIRED"
    if r.incr(f"otp:attempts:{phone}") > MAX_ATTEMPTS:
        r.delete(f"otp:code:{phone}")                # burn the code
        return "LOCKED"
    r.expire(f"otp:attempts:{phone}", OTP_TTL_S)
    if not hmac.compare_digest(stored, _digest(phone, otp)):
        return "WRONG"
    r.delete(f"otp:code:{phone}", f"otp:attempts:{phone}")   # single use
    return "OK"

(With redis-py, r.get returns bytes unless the client is created with decode_responses=True; use that, or encode the digest before comparing.) Why each piece exists:

  • Hash, not plaintext: anyone with Redis read access (or a memory dump) cannot log in as users.
  • Attempt cap: a 6-digit code has 1,000,000 possibilities; 5 attempts give an attacker a 0.0005% chance per code. Without a cap, brute force takes minutes.
  • Cooldown and send caps: stop users (and attackers) from spamming sends.
  • Single use + short TTL: a leaked old code is useless.

Rate limits in layers

KeyExample limitStops
Phone number1 per 30 s, 5 per hour, 10 per dayHarassment (bombing a victim with OTPs), brute force on one account
Device id / app install10 numbers per dayOne device cycling through many numbers
IP / subnetAdaptive, higher for carrier NAT rangesScripted abuse from servers; mobile carriers share IPs, so be careful
Country prefix / number rangeSpike detectionSMS pumping to premium-rate ranges
GlobalBudget alarmRunaway cost

The algorithms are covered in design a rate limiter; Redis primitives in the Redis deep dive.

⚠️
SMS pumping fraudAttackers trigger OTP sends to number ranges they profit from (artificially inflated SMS traffic). Your login page becomes their revenue source. Defences: send caps per prefix, conversion monitoring (sends vs successful verifications per range), bot detection or app attestation (Play Integrity, App Attest) before sending, and blocking unneeded international destinations.

Delivery reliability

  • Multiple SMS providers with health-based routing; fail over if delivery receipts drop or latency rises. In India, templates and sender ids must be registered on the DLT platform for each provider route.
  • Channel fallback: WhatsApp OTP or a voice call after a failed SMS.
  • Measure send-to-verify conversion and time-to-verify per provider and operator; that is your real SLA.

Deep Dive 2: Sessions vs JWT

Server-side session (opaque id)JWT access token
What the client holdsRandom id, e.g. 32 bytesSigned claims (user, scopes, expiry), typically hundreds of bytes to a few KB
ValidationLookup in session store per requestVerify signature locally with public key
RevocationDelete the session: instantHard until expiry; needs denylist or short TTL
MicroservicesEvery service needs the session store, or the gateway translatesAny service can verify independently
Data freshnessAlways current (roles changed? next request sees it)Claims stale until token refresh
Failure modeSession store down = everyone logged outSigning key leak = attacker can mint tokens

The hybrid most large systems use

  • Access token: JWT, signed with an asymmetric key (RS256/ES256), 5 to 15 minutes lifetime, minimal claims: sub, sid (session id), scope, iat, exp, jti, aud. Validated locally by the gateway and services.
  • Refresh token: opaque random string, stored hashed server-side, long-lived (days to weeks for mobile), bound to a device/session record, rotated on every use.
  • The session record (user, device, created_at, last_seen, IP, status) is the unit you list in "your devices" and revoke on logout.

Where tokens live on the client

  • Web: refresh token (or session id) in an HttpOnly; Secure; SameSite=Lax/Strict cookie scoped to the auth path, so JavaScript cannot read it (XSS cannot steal it). Access token in memory. Add CSRF protection for cookie-authenticated endpoints.
  • Mobile: refresh token in the Android Keystore-backed storage / iOS Keychain; access token in memory.
  • Never put long-lived tokens in localStorage; any XSS bug leaks them.

Deep Dive 3: Refresh Tokens with Rotation and Reuse Detection

A stolen long-lived refresh token is the worst credential leak short of a password. Rotation limits the damage: every refresh returns a new refresh token and marks the old one used. If an old one is presented again, either the attacker or the real client is replaying it, and you cannot tell which, so you revoke the whole token family.

Python: rotation with reuse detection
import hashlib
import secrets
import time

REFRESH_TTL_S = 30 * 24 * 3600

def _h(token: str) -> str:
    return hashlib.sha256(token.encode()).hexdigest()   # store only hashes

class RefreshTokens:
    def __init__(self, db):
        self.db = db            # token_hash -> {user, family, used, exp}

    def issue(self, user_id, family=None):
        token = secrets.token_urlsafe(32)
        self.db[_h(token)] = {"user": user_id, "family": family or secrets.token_hex(8),
                              "used": False, "exp": time.time() + REFRESH_TTL_S}
        return token

    def rotate(self, token):
        rec = self.db.get(_h(token))
        if rec is None or rec["exp"] < time.time():
            raise PermissionError("invalid refresh token")
        if rec["used"]:
            # reuse of an already-rotated token: likely stolen. Kill the whole family.
            for r in self.db.values():
                if r["family"] == rec["family"]:
                    r["exp"] = 0
            raise PermissionError("refresh token reuse detected; session revoked")
        rec["used"] = True
        return rec["user"], self.issue(rec["user"], rec["family"])

store = RefreshTokens({})
t1 = store.issue("u42")
user, t2 = store.rotate(t1)          # normal rotation
store.rotate(t1)                     # replay -> PermissionError, family revoked
                                     # store.rotate(t2) now also fails

In production, the family is the session id, the store is Redis or a sharded table with an index on family, and the "revoke family" is a single update on the session record rather than a scan. Two practical refinements:

  • Concurrency grace window: two browser tabs may refresh simultaneously with the same token. Allow the just-rotated token to be reused for a few seconds and return the same new token, instead of nuking the session.
  • Absolute lifetime: even with rotation, cap a session at, say, 90 days, after which the user re-authenticates.

Deep Dive 4: Token Revocation That Works

"Log out from all devices", "password changed", "account compromised", "employee left". The problem: up to 15 minutes of valid JWTs are still out there.

MechanismHowLatency to take effectCost
Revoke refresh token / sessionMark session revoked; refresh failsUp to access token TTLTrivial
Short access TTL5 to 15 minutesBounded by TTLMore refresh traffic
jti denylistRedis set of revoked token ids with TTL = remaining token life; pushed to gateway memorySecondsSmall: only revoked tokens are listed
Per-user "valid after"user:42:tokens_valid_after = ts; reject tokens with iat earlierSecondsOne cached lookup per user
Per-session statusGateway checks sid against a revoked-sessions setSecondsSmall set, cacheable

A solid answer combines them: revoke the session (no more refreshes), add the user's "valid after" timestamp or the session id to a revocation set replicated to every gateway node (via Redis pub/sub or a stream), and let entries expire once all affected access tokens have expired. Because revocations are rare relative to requests, the set stays tiny and the check is an in-memory hash lookup. Where the gateway does this check is covered in API gateway design.

Deep Dive 5: Single Sign-On with OpenID Connect

SSO means the user authenticates once with an identity provider (IdP), and many apps (relying parties) trust that. The modern standard is OpenID Connect (OIDC) on top of OAuth 2.0; enterprises also use SAML 2.0, especially for older SaaS integrations.

Authorization code flow with PKCE

  1. App redirects the browser to https://id.example.com/authorize?response_type=code&client_id=seller-portal&redirect_uri=…&scope=openid profile&state=…&nonce=…&code_challenge=…&code_challenge_method=S256.
  2. IdP checks its own session cookie. No session: show login (OTP, password, Google, corporate IdP), then MFA if required, and set the IdP session cookie on id.example.com.
  3. IdP redirects back with a short-lived, single-use code and the original state (the app verifies state to prevent CSRF).
  4. App's backend exchanges the code at POST /token with the code_verifier (PKCE proves the same client started the flow; mandatory for mobile and SPA public clients).
  5. IdP returns an ID token (JWT about the user: iss, sub, aud, exp, nonce), an access token for APIs, and optionally a refresh token.
  6. App validates the ID token: signature via the IdP's JWKS, iss, aud equals its client id, not expired, nonce matches. Then it creates its own local session.
  7. Second app: the same redirect happens, but the IdP session cookie exists, so the IdP immediately redirects back with a code. That is the "single" in single sign-on.

Design details interviewers like

  • Discovery and JWKS: apps read /.well-known/openid-configuration and cache the JWKS; keys rotate with overlapping validity and are identified by kid.
  • Federation: the company IdP can itself delegate to Google or to a corporate IdP (Azure AD/Entra ID, Okta) for employees, mapping external identities to internal user ids by verified email or immutable subject id.
  • Single logout is hard: logging out of the IdP does not automatically end each app's local session. OIDC defines front-channel and back-channel logout so the IdP can notify apps; apps must honour it and keep their local sessions short.
  • Consent and scopes for third-party clients; first-party apps can skip consent screens.
  • Do not use the implicit flow (tokens in the URL fragment); current best practice for browser and mobile apps is authorization code with PKCE.

MFA and Step-Up Authentication

Phone OTP is "something you have" (the SIM), but it is vulnerable to SIM swap fraud and phishing. For higher-risk accounts and actions, add factors:

  • TOTP (authenticator apps, RFC 6238): a shared secret plus the current 30-second time step through HMAC.
  • Passkeys / WebAuthn: phishing-resistant public-key credentials bound to the site's origin; the strongest practical option for consumers today.
  • In-app approval on an already-trusted device ("Is this you logging in from Pune?").
  • Device binding: a key pair created on first login, stored in secure hardware; later requests are signed, so stolen tokens alone are not enough. Indian payment apps commonly combine device binding with the SIM/number for exactly this reason.
Python: TOTP (RFC 6238), verified against the RFC test vectors
import hashlib
import hmac
import struct
import time

def hotp(secret: bytes, counter: int, digits: int = 6) -> str:
    mac = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest()
    offset = mac[-1] & 0x0F                                   # dynamic truncation
    code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
    return str(code % 10 ** digits).zfill(digits)

def totp(secret: bytes, at: float = None, step: int = 30, digits: int = 6) -> str:
    t = time.time() if at is None else at
    return hotp(secret, int(t // step), digits)

def verify_totp(secret: bytes, code: str, at: float = None, window: int = 1) -> bool:
    t = time.time() if at is None else at
    return any(hmac.compare_digest(totp(secret, t + i * 30), code)
               for i in range(-window, window + 1))      # tolerate small clock skew

secret = b"12345678901234567890"                          # RFC 6238 test key (SHA-1)
print(totp(secret, at=59, digits=8))                     # 94287082
print(totp(secret, at=1111111109, digits=8))             # 07081804

Store TOTP secrets encrypted (envelope encryption with a KMS), and record the last accepted time step per user so the same code cannot be replayed within its window.

Step-up authentication

Instead of MFA on every login (friction), require a fresh strong factor for sensitive actions: changing the phone number, adding a payout bank account, large withdrawals, viewing full card details, admin operations. Implement it with an auth_time and acr/amr claim in the token; the API rejects the request with "step-up required" unless the token shows a recent strong authentication. This pattern is essential in wallet and payment systems.

Security Hardening Checklist

  • Passwords (if supported): hash with Argon2id, scrypt or bcrypt with per-user salt; never fast hashes like SHA-256 alone. Check against breached-password lists.
  • Credential stuffing: rate limit per account and per IP, detect many accounts tried from one source, bot detection, and lock or challenge rather than permanently lock (which enables denial of service on victims).
  • No user enumeration: identical responses and timings for "unknown phone/email" and "wrong code".
  • Signing keys in a KMS/HSM; rotate regularly; publish new keys in JWKS before using them; keep old keys until their tokens expire.
  • Validate JWTs strictly: pin the expected algorithm (never accept alg: none or algorithm switching), check iss, aud, exp, nbf.
  • Risk engine: new device, new city, impossible travel, emulator, rooted device → require extra verification.
  • SIM swap signals: some telecom operators expose APIs for recent SIM change; treat a recent swap as high risk for sensitive actions.
  • Audit log of every login, refresh failure, revocation and MFA change, streamed to a tamper-evident store and used for user-facing "recent activity".

Scaling and Availability

  • Hot path is local: 300k token validations/s happen in gateway memory (signature check + revocation set lookup). The auth service is only on the path for logins and refreshes.
  • Auth service is stateless behind a load balancer; state lives in Redis (OTP codes, rate counters, revocation sets) and a replicated SQL database (users, devices, sessions, MFA).
  • Multi-region: users and sessions replicated across regions; OTP state can stay regional (a 5-minute code does not need global replication if requests are routed consistently).
  • Graceful degradation: if the auth service is down, existing access tokens still work until expiry, and clients retry refresh with backoff. If Redis is down, decide: fail closed for OTP sends (to prevent abuse) and fail open for revocation checks only if you accept the risk.
  • Thundering herd at expiry: add jitter to refresh timing on clients so millions of tokens issued during a launch do not all refresh at the same second.

The Interview Angle

Questions you should be ready for

  • "Why not just use JWTs with a 30-day expiry?" Because you cannot revoke them, and a stolen token is valid for a month. Short access + rotated refresh.
  • "Why not store sessions in the database and look them up every request?" Fine at small scale; at 300k req/s across 50 services it is a central bottleneck and a single point of failure. Show the numbers.
  • "How do you prevent OTP brute force?" Attempt cap per code, codes hashed and single-use, short TTL, per-phone and per-device limits.
  • "A user reports their account was hijacked. What do you do?" Revoke all sessions (valid-after timestamp), force re-auth with step-up, review audit logs, check for changed phone/email/payout details, notify the user.
  • "How does a second app log the user in without asking again?" IdP session cookie + authorization code flow.
  • "What is in your JWT?" Minimal claims; no PII beyond what services need; remember it is only signed, not encrypted, so anyone holding it can read it.

Common mistakes

🚫
"We use OAuth for login"OAuth 2.0 alone is authorisation, not authentication. Saying OIDC, ID token, authorization code flow with PKCE and how you validate the ID token is what demonstrates understanding.
  • Storing OTPs or refresh tokens in plaintext.
  • No attempt limit on OTP verification.
  • Long-lived JWTs in localStorage.
  • Validating tokens by calling the auth service on every request.
  • Forgetting logout-all-devices and revocation entirely.
  • Trusting client-sent user id headers past the gateway.
  • Treating SMS OTP as strong enough for high-risk actions.
💡
A 20-second summary"OTP login with hashed single-use codes and layered rate limits; short-lived JWT access tokens validated locally at the gateway; rotated refresh tokens tied to a session record with reuse detection; a tiny replicated revocation set for instant logout; OIDC authorization code with PKCE for SSO; step-up MFA for sensitive actions." Then let the interviewer choose the deep dive.

For REST-side details such as status codes for auth failures (401 vs 403) and header conventions, see REST API interview questions.

Frequently Asked Questions

Should I use sessions or JWT for authentication?▾
Server-side sessions are simple to revoke and keep tokens small, but every request needs a session lookup. JWTs can be validated locally without a lookup, which scales well across microservices, but are hard to revoke before expiry. Most large systems use short-lived JWT access tokens plus server-side refresh tokens or sessions to get both benefits.
How do you design a secure OTP login?▾
Generate the OTP with a cryptographically secure random generator, store only a hash with a short expiry, allow a small number of verification attempts, enforce resend cooldowns and per-phone, per-IP and per-device send limits, make codes single-use, and monitor for SMS pumping fraud. Use multiple SMS providers with failover.
What is refresh token rotation?▾
Each time a client uses a refresh token to get a new access token, the server issues a new refresh token and invalidates the old one. If an old refresh token is ever presented again, the server assumes it was stolen and revokes the whole token family, logging out both the attacker and the legitimate client.
How do you revoke a JWT before it expires?▾
Keep access tokens short-lived, and for immediate revocation check a small denylist of revoked token ids or a per-user tokens-valid-after timestamp at the gateway, cached in memory or Redis. Revoking the refresh token or session prevents new access tokens from being issued.
How does single sign-on work with OpenID Connect?▾
Apps redirect the user to a central identity provider using the authorization code flow with PKCE. The identity provider authenticates the user once and keeps its own session cookie, then returns an authorization code that each app exchanges for an ID token and access token. A second app gets a code without a new login because the IdP session already exists.
What is the difference between OAuth 2.0 and OpenID Connect?▾
OAuth 2.0 is an authorization framework for granting an app access to resources via access tokens. OpenID Connect is an identity layer on top of OAuth 2.0 that adds the ID token, a signed JWT describing who the user is, plus standard discovery and userinfo endpoints, which is what you need for login and SSO.
Is SMS OTP secure enough for login?▾
SMS OTP is convenient and widely used in India, but it is vulnerable to SIM swap, SMS interception and social engineering. Pair it with device binding and risk checks, and require stronger factors such as TOTP, passkeys or in-app approval for sensitive actions like changing payout details.

Prepare for Security-Heavy System Design Rounds

Prepflix's system design course covers auth, SSO, gateways and payments the way fintech and product-company panels test them, with mock interviews and detailed feedback.

Explore the Prepflix Course →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.