TL;DR

1. The problem: why authentication and authorization are hard at Instagram

Instagram has well over a billion accounts. Each account holds private photos, private messages, a phone number and an email, and for many people it's their business or livelihood. Every one of the millions of API requests per second must answer two questions correctly:

  1. Who is making this request? Is it really @alice on her phone, or an attacker who stole her password, or a bot?
  2. Is that person allowed to do this? Can Bob see this post (Alice's account is private and Bob doesn't follow her)? Can Bob delete this comment (only if he wrote it or owns the post)? Can Carol DM Alice (not if Alice blocked her)?

And it must do this fast (a millisecond or less of overhead), at massive scale, while being resistant to constant attack: credential stuffing (trying leaked passwords from other sites), phishing, SIM-swap attacks on SMS codes, stolen tokens and automated account creation. A single bug in an authorization check can leak private photos of millions of people.

🧠 Analogy At an airport, the passport check is authentication: an officer verifies that you are who you claim to be. Your boarding pass is authorization: it says which flight, which seat and which lounge you're allowed into. Once you've passed security you get a wristband-like boarding pass instead of showing your passport at every door (that's the token). And the gate agent still checks this boarding pass is for this flight (that's the object-level check) — being inside the airport doesn't let you board any plane you like.

2. Authentication vs authorization

Authentication (AuthN)Authorization (AuthZ)
Question"Who are you?""What are you allowed to do?"
InputCredentials: password, OTP code, passkey, social login, a tokenAn authenticated identity + the action + the resource + context
OutputA verified identity (user ID), usually materialised as a session or tokenAllow / deny (sometimes "allow with filters")
FrequencyFull login rarely; token check on every requestOn every request and often for every object returned
Failure status401 Unauthorized (confusingly named — it means unauthenticated)403 Forbidden (or 404 to avoid revealing existence)
Instagram exampleLogging in with username + password + 2FA codeHiding a private account's posts from non-followers

3. Storing passwords safely

The first rule: never store passwords in plain text, and never store them with reversible encryption. Databases get leaked — through backups, SQL injection, insiders or misconfigured buckets. If that happens, attackers must not be able to recover users' passwords (which people reuse on their bank and email).

3.1 Hashing, and why plain SHA-256 is not enough

A cryptographic hash function turns any input into a fixed-size fingerprint, one-way: easy to compute H(password), infeasible to reverse. At login you compute H(submitted) and compare it to the stored hash.

But general-purpose hashes like SHA-256 are designed to be fast — a modern GPU computes billions of SHA-256 hashes per second. Since human passwords come from a small space (dictionary words, names, "Summer2024!"), an attacker with a leaked table can simply try every likely password:

Assume an attacker's GPU rig does 10 billion SHA-256/s.
All 8-character lowercase+digit passwords: 36^8 ≈ 2.8 × 10^12 → ~280 seconds.
A 10-million-word dictionary × 1,000 common mutations = 10^10 guesses → 1 second.

With bcrypt at cost 12 (~250 ms per hash on a server CPU; GPUs gain little):
  roughly a few thousand guesses/s per GPU → the same dictionary takes months per user.

Plain hashes also have two more problems: identical passwords produce identical hashes (crack one, crack all), and attackers can use precomputed rainbow tables.

3.2 Salt

A salt is a random value (e.g. 16 bytes), unique per user, generated at signup and stored next to the hash. We hash H(salt + password). Now:

Salts are not secret. Their job is uniqueness, not secrecy.

3.3 Slow, memory-hard password hashing: bcrypt, scrypt, Argon2

AlgorithmKey ideaTunable parametersNotes
bcrypt (1999)Deliberately expensive key setup based on Blowfish; built-in saltCost factor (2^cost iterations; 10–12 typical)Battle-tested, everywhere; input truncated at 72 bytes; not memory-hard
scrypt (2009)Memory-hard: requires lots of RAM, which makes GPU/ASIC attacks expensiveN (CPU/memory cost), r, pGood choice; used in some cryptocurrencies
Argon2id (2015)Winner of the Password Hashing Competition; memory-hard and side-channel resistantMemory (e.g. 64 MiB), iterations, parallelismCurrent best-practice recommendation (OWASP)

All of them produce a self-describing string that stores algorithm, parameters, salt and hash together, e.g.:

$argon2id$v=19$m=65536,t=3,p=1$c29tZXNhbHRzb21lc2FsdA$Rdescudvc4K1dYtr5Pe0rI2RhWw0eQ3J2X2sW6mY1kE
$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW    (bcrypt, cost 12)

Because the parameters are stored in the string, you can raise the cost over time: on a user's next successful login, verify with the old parameters, then re-hash with the new ones.

3.4 Pepper

A pepper is a secret value shared by all users, stored outside the database (in a secrets manager or HSM). Typically you compute HMAC(pepper, password) and then Argon2 that, or encrypt the final hash with a key in the HSM. If only the database leaks, the attacker can't even start guessing without the pepper. The cost: pepper rotation is awkward (you can't re-hash without the plaintext, so you keep versions).

signup(password):
    salt   = random_bytes(16)
    peppered = HMAC_SHA256(PEPPER_v3, password)
    hash   = argon2id(peppered, salt, m=64MiB, t=3, p=1)
    store(user_id, hash_string, pepper_version=3)

login(username, password):
    row = users.find(username)                 # ALWAYS do the hash work, even if user not found
    if row is None: argon2id(dummy...); return FAIL   # avoid timing leaks revealing which usernames exist
    peppered = HMAC_SHA256(PEPPER[row.pepper_version], password)
    if constant_time_equals(argon2id_verify(row.hash_string, peppered)):
        maybe_rehash_with_stronger_params(...)
        return OK
    return FAIL
💡 Capacity note Slow hashing is deliberate, but it costs your servers too. Assume 20k logins/s at peak × 100 ms CPU each = 2,000 CPU cores just for password verification. That's why login endpoints are heavily rate limited (attackers could otherwise DoS you by forcing expensive hashes), and why most requests use cheap token checks instead of passwords.

4. Staying logged in: sessions vs tokens

After you prove your password once, the server gives the client a credential to present on every subsequent request. Two families exist.

4.1 Session-based authentication (stateful)

  1. On login, the server generates a long random session ID (e.g. 128+ bits from a CSPRNG).
  2. It stores session_id → {user_id, device_id, created_at, expires_at, ip} in a session store — typically Redis (fast, TTL support) backed by a durable DB.
  3. The client sends the session ID on every request: in a cookie for web (Set-Cookie: sessionid=…; HttpOnly; Secure; SameSite=Lax) or a header for mobile.
  4. The server looks up the ID on each request. Found and unexpired → authenticated as that user.
  5. Logout or "log out of all devices" = delete the session(s). Revocation is instant.

The session ID itself means nothing — it's an opaque pointer, like a coat-check ticket. Costs: a store lookup (~0.5 ms in Redis) on every request, and the session store becomes critical infrastructure that must scale and replicate (assume 2B active sessions × ~200 bytes ≈ 400 GB — sharded across a Redis cluster).

4.2 Token-based authentication with JWT (stateless)

A JSON Web Token (JWT) is a self-contained, signed credential. The server puts the claims (user ID, expiry, scopes) into the token and signs it. Any server holding the verification key can check it without a database lookup. A JWT has three Base64URL-encoded parts separated by dots: header.payload.signature.

eyJhbGciOiJSUzI1NiIsImtpZCI6ImsyMDI2In0.eyJzdWIiOiIxMjM0IiwiZXhwIjoxNzkzMDk5ODAwfQ.kX9f…Qz Header Payload (claims) Signature { "alg": "RS256", "kid": "k2026", "typ": "JWT" } { "sub": "1234", "iss": "auth.instagram", "aud": "api", "did": "d-55", "iat": 1793098900, "exp": 1793099800 } RSA_sign( private_key, b64(header) + "." + b64(payload) )
A JWT: the header says how it's signed, the payload carries claims (Base64-encoded, NOT encrypted), and the signature proves nobody altered them.

Signing: HS256 vs RS256 (and ES256/EdDSA)

HS256 (HMAC + SHA-256)RS256 (RSA signature + SHA-256)
Key typeSymmetric: one shared secret signs and verifiesAsymmetric: private key signs, public key verifies
Who can verifyAnyone with the secret — who can therefore also forge tokensAnyone with the public key — they cannot forge
SpeedVery fastVerify fast, sign slower; tokens bigger (256-byte signature for 2048-bit keys)
Key distributionSecret must be copied to every verifier — larger blast radius if leakedPublic keys published (e.g. a JWKS endpoint); only the auth service holds the private key
Best forA single service that both issues and verifiesMany verifiers (gateway fleet, many services, third parties) — Instagram's case

ES256 (ECDSA) and EdDSA (Ed25519) are asymmetric like RS256 but with much smaller keys and signatures — increasingly preferred. Classic JWT pitfalls: accepting "alg": "none", or letting an attacker switch RS256 to HS256 and sign with the public key as an HMAC secret. Always pin the expected algorithm in the verifier.

Stateless validation

verify(token):
    header, payload, sig = split(token, ".")
    h = json(b64decode(header))
    if h.alg != "RS256": reject                     # pin algorithm
    key = jwks_cache[h.kid]                          # public keys cached locally, refreshed periodically
    if not rsa_verify(key, header + "." + payload, sig): reject
    claims = json(b64decode(payload))
    if claims.exp < now() - 30s_skew: reject (401, "token expired")
    if claims.iss != "auth.instagram" or claims.aud != "api": reject
    return claims.sub   # user id — no database call needed

The revocation problem

Statelessness is a double-edged sword: if a token is valid by signature and expiry, every server will accept it — even after the user logs out, changes their password, or the token is stolen. Options:

4.3 Sessions vs JWTs compared

Opaque session ID + storeJWT (self-contained)
Server stateSession store requiredNone for validation
Per-request costNetwork lookup (~0.5 ms)Local signature check (~0.05–0.2 ms)
RevocationInstant — delete the sessionHard — wait for expiry or maintain a denylist
Cross-serviceEvery service needs store access (or gateway translates)Any service with the public key can verify
Data freshnessAlways currentClaims frozen until expiry (e.g. stale roles)

Large systems usually combine them: an opaque or JWT access token checked at the gateway, plus a refresh token recorded server-side in a sessions table so it can be listed ("where you're logged in") and revoked.

5. Access tokens, refresh tokens and rotation

Access tokenRefresh token
PurposeSent on every API callUsed only to obtain new access tokens
LifetimeShort: minutes to an hourLong: weeks to months (sliding)
FormatOften JWT (stateless check)Opaque random string, stored hashed in the DB (stateful, revocable)
Sent toEvery API endpoint via the gatewayOnly the auth service's /token endpoint
Stored on deviceMemorySecure storage: iOS Keychain / Android Keystore; HttpOnly cookie on web

Refresh token rotation: every time the client uses a refresh token, the server issues a new refresh token and invalidates the old one. If an old refresh token is ever presented again, someone has stolen and replayed it — the server revokes the entire token family (that device's session) and forces re-login.

 App                                     Auth Service                         sessions DB
  | -- GET /v1/feed (access A1, expired) --> Gateway: 401 token_expired          |
  | -- POST /v1/auth/token {refresh: R1} ---> |  lookup hash(R1): valid, family F |
  |                                          |  mark R1 used; create R2 --------->|
  | <-- {access: A2 (15 min), refresh: R2} --|                                   |
  | -- GET /v1/feed (A2) --> 200 OK                                              |
  |                                                                              |
  |   ... attacker replays stolen R1 later ...                                   |
  |   Attacker -- {refresh: R1} -----------> |  R1 already used! → REUSE DETECTED |
  |                                          |  revoke whole family F ---------->|
  |   (both attacker and real app must log in again; user alerted)               |

6. OAuth 2.0, PKCE and OpenID Connect

6.1 What OAuth 2.0 solves

OAuth 2.0 is a framework for delegated authorization: letting an application access resources on a user's behalf without ever seeing the user's password. Example: a scheduling tool for businesses wants to publish posts to your Instagram professional account. Before OAuth, you'd have to give it your password — full access, forever. With OAuth, you grant it a limited-scope token (e.g. "publish content") that you can revoke.

The four roles

👤 Resource owner

The user who owns the data and grants access (you).

📲 Client

The app wanting access (the scheduling tool, or Instagram's own mobile app).

🔐 Authorization server

Authenticates the user, shows the consent screen, issues tokens (Instagram/Meta's auth service).

🗄️ Resource server

The API that accepts access tokens (Instagram's API behind the gateway).

Key terms: scopes (what access is requested, e.g. profile:read media:publish), redirect URI (where the user is sent back with a code), authorization code (a short-lived one-time code exchanged for tokens), grant types (authorization code, client credentials for machine-to-machine, refresh token; the old "implicit" and "password" grants are deprecated).

6.2 Authorization code flow with PKCE

Mobile apps and single-page web apps are public clients: they cannot keep a client secret (anyone can decompile the app). A malicious app on the same phone could register the same custom URL scheme and intercept the authorization code. PKCE (Proof Key for Code Exchange, pronounced "pixie") fixes this:

  1. The app generates a random code_verifier (43–128 chars) and keeps it secret in memory.
  2. It sends code_challenge = BASE64URL(SHA256(code_verifier)) with the authorization request.
  3. When exchanging the code for tokens, it must present the original code_verifier. The server hashes it and compares. An attacker who intercepted only the code can't produce the verifier.
 User        Mobile App (client)          System Browser          Auth Server              Resource Server (API)
  |  tap "Log in"   |                            |                        |                              |
  |---------------> | gen code_verifier V        |                        |                              |
  |                 | C = b64url(sha256(V))      |                        |                              |
  |                 |-- open /authorize?response_type=code&client_id=app                                |
  |                 |   &redirect_uri=app://cb&scope=openid profile&state=S&code_challenge=C           |
  |                 |   &code_challenge_method=S256 -->|                        |                              |
  |                 |                            |-- GET /authorize ----->|                              |
  |  <------------- login page + consent --------|<-----------------------|                              |
  |-- password + 2FA, "Allow" ------------------>|----------------------> | verify user, store C          |
  |                 |                            |<-- 302 app://cb?code=K&state=S                         |
  |                 |<-- redirect (code K, state S) ----|                 |                              |
  |                 | check state == S (CSRF)    |                        |                              |
  |                 |-- POST /token {code=K, code_verifier=V, redirect_uri} ----->|                       |
  |                 |                            |                        | sha256(V) == C ? ✔           |
  |                 |<-- {access_token, refresh_token, id_token} ---------|                              |
  |                 |-- GET /v1/feed  Authorization: Bearer access_token --------------------------------->|
  |                 |<-- 200 feed JSON -----------------------------------------------------------------|

Notes: use the system browser (ASWebAuthenticationSession / Custom Tabs), not an embedded WebView, so the app can't snoop on the password and the user benefits from existing browser sessions. The state parameter defends against CSRF. Codes are single-use and expire in ~1 minute. For confidential server-side clients, the client also authenticates with its secret — and PKCE is now recommended for them too (OAuth 2.1).

6.3 OpenID Connect (OIDC)

OAuth 2.0 is about authorization ("this app may publish posts"); it doesn't define how to tell the app who the user is. OpenID Connect is a thin identity layer on top: when the client requests the openid scope, the authorization server also returns an ID token — a JWT about the user's authentication:

{
  "iss": "https://auth.example-idp.com",
  "sub": "8f2c1e",              // stable user identifier at this provider
  "aud": "instagram-client-id", // this ID token is for Instagram only
  "exp": 1793099800, "iat": 1793099200,
  "nonce": "n-0S6_WzA2Mj",      // replay protection, echoed from the request
  "email": "alice@example.com", "email_verified": true,
  "name": "Alice"
}

OIDC also defines a /userinfo endpoint, discovery documents (/.well-known/openid-configuration) and JWKS public keys. Rule of thumb: ID token = for the client, to know who logged in; access token = for the API, to authorize calls. Never send the ID token to APIs as an access token.

6.4 SSO and "Log in with Facebook"

Single sign-on (SSO) means one authentication grants access to multiple applications. Instagram offers "Continue with Facebook" and, through Meta's Accounts Center, linked accounts across Meta apps. Conceptually:

  1. The user taps "Continue with Facebook". The Instagram app starts an OAuth/OIDC-style flow with Facebook as the identity provider (IdP); if the Facebook app is installed and logged in, the user just confirms.
  2. Facebook returns a code/token proving identity for a Facebook user ID, with consented scopes.
  3. Instagram's Auth service verifies it server-side (signature, audience = Instagram, expiry), then looks up a linked identity row: (provider="facebook", provider_user_id) → instagram user_id.
  4. If linked → issue Instagram's own access + refresh tokens. If not linked → offer to create an account or link an existing one (after verifying it with password).

Important: Instagram issues its own session/tokens afterwards; the external IdP is only used to prove identity at login.

7. Multi-factor authentication (MFA / 2FA)

MFA requires two or more independent factors: something you know (password), something you have (phone, security key), something you are (biometrics). A stolen password alone is then not enough.

7.1 TOTP (authenticator apps)

Time-based One-Time Passwords (RFC 6238): at enrolment, server and authenticator app share a random secret (via a QR code). Both compute a 6-digit code from the secret and the current 30-second time step:

T    = floor(unix_time / 30)
hmac = HMAC_SHA1(shared_secret, T as 8-byte big-endian)
off  = hmac[19] & 0x0F
code = (int32(hmac[off..off+3]) & 0x7FFFFFFF) % 1_000_000     # 6 digits

server: accept codes for T-1, T, T+1 (clock drift); reject a code already used (replay);
        rate-limit attempts (1,000,000 possibilities → must cap guesses).

Works offline, no SMS cost, not interceptable over the phone network.

7.2 SMS codes and their risks

SMS is still far better than nothing, but prefer authenticator apps, push approval on a logged-in device, or passkeys / security keys (WebAuthn/FIDO2 — phishing-resistant because the signature is bound to the real domain). Instagram offers authenticator-app, SMS and WhatsApp codes for 2FA, plus backup codes, and trusted-device login approvals. Use step-up authentication — extra verification only for risky events such as a new-country login or changing email/password — to keep everyday friction low.

8. Authorization models

8.1 RBAC — role-based access control

Permissions are attached to roles, and users get roles: admin, moderator, user. Simple and auditable. Instagram examples: internal content moderators can view reported content; a business account can grant a teammate the "advertiser" role on its page. RBAC alone can't express "Bob can see Alice's post because Bob follows Alice".

8.2 ABAC — attribute-based access control

Decisions are rules over attributes of user, resource, action and environment: allow if resource.visibility == "public" or (viewer.age >= 18 and resource.age_restricted) …. Very flexible (time, location, device trust), but rules can become hard to reason about. Policy engines like OPA (Rego) or AWS Cedar express such rules.

8.3 Relationship-based (ReBAC) and ownership checks

Social apps mostly need relationship-based authorization: access depends on the graph between viewer and owner. Google's Zanzibar paper popularised this model (open-source descendants: SpiceDB, OpenFGA). For Instagram:

RuleCheckData needed
OwnershipOnly the author can edit/delete a post; post owner or comment author can delete a commentpost.author_id == viewer_id
Private accountsPosts/stories visible only to approved followersAccount privacy flag + follow edge (status = accepted) from the graph store
BlockingIf either user blocked the other: can't see profile, posts, comments; can't DM, follow or mentionBlock edges in both directions
Close FriendsA close-friends story is visible only to users on the author's listAuthor's close-friends list membership
def can_view_post(viewer_id, post):
    author = post.author_id
    if viewer_id == author:                       return True
    if graph.is_blocked_either_way(viewer_id, author): return False
    if post.audience == "close_friends":
        return graph.in_close_friends(author, viewer_id)
    if users.is_private(author):
        return graph.follows(viewer_id, author, status="accepted")
    return True            # public account

These checks run for every object returned — a feed page of 20 posts from 20 authors means batching: fetch all block/follow edges in one call (graph.batch_check(viewer, [authors])) and cache aggressively, with careful invalidation (an unfollow or block must take effect quickly).

8.4 IDOR: the most common authorization bug

Insecure Direct Object Reference (IDOR) happens when an API takes an object ID from the client and returns or modifies that object without checking the caller is allowed to. The gateway authenticated the user, so the developer assumed "logged in = allowed":

# VULNERABLE: any logged-in user can read any post, including private accounts' posts
GET /v1/posts/987654
def get_post(request, post_id):
    return db.posts.get(post_id)

# VULNERABLE: any user can delete anyone's comment by changing the ID
DELETE /v1/comments/555
def delete_comment(request, comment_id):
    db.comments.delete(comment_id)

# FIXED
def delete_comment(request, comment_id):
    c = db.comments.get(comment_id) or raise NotFound()
    post = db.posts.get(c.post_id)
    if request.user_id not in (c.author_id, post.author_id):
        raise NotFound()          # 404 rather than 403 avoids confirming the object exists
    db.comments.delete(comment_id)

Defences: centralise authorization helpers (can_view_post) and make every data-access path call them; deny by default; write tests that attempt cross-user access; use non-sequential IDs (helps against enumeration but is not a fix on its own). Publicly disclosed bug-bounty reports against large social apps, Instagram included, have featured exactly this class of bug — e.g. viewing private content by manipulating media IDs.

9. Where the checks happen

📱 AppBearer token API GatewayAuthN: verify tokencoarse AuthZ: scopes→ X-User-Id: 1234 Post / Feed Serviceobject-level AuthZ:private? blocked?close friends? owner? Follow/Graph Post metadata User DB (privacy) fails → 401 fails → 403 / 404 Identity is established once at the edge; permission is decided where the data lives.
The gateway answers "who"; the owning service answers "may they, for this object".

10. Designing the User/Auth service

10.1 Data model

Account data lives in the User DB — in Instagram's architecture, sharded PostgreSQL with replicas (see Sharding and Replication). Assume:

CREATE TABLE users (
  user_id          BIGINT PRIMARY KEY,          -- 64-bit ID (see id-generation)
  username         VARCHAR(30) NOT NULL,         -- stored lower-cased/normalised
  display_name     VARCHAR(64),
  email            VARCHAR(255),
  email_verified   BOOLEAN DEFAULT FALSE,
  phone_e164       VARCHAR(16),
  phone_verified   BOOLEAN DEFAULT FALSE,
  password_hash    TEXT,                          -- argon2id/bcrypt string; NULL for social-only
  pepper_version   SMALLINT,
  is_private       BOOLEAN DEFAULT FALSE,
  account_status   VARCHAR(16) DEFAULT 'active',  -- active, disabled, deactivated, locked
  mfa_enabled      BOOLEAN DEFAULT FALSE,
  token_version    INT DEFAULT 0,                 -- bump to invalidate all access tokens
  created_at       TIMESTAMPTZ NOT NULL,
  updated_at       TIMESTAMPTZ NOT NULL
);

CREATE TABLE usernames (            -- global uniqueness lookup (unsharded or sharded by username)
  username_normalized VARCHAR(30) PRIMARY KEY,
  user_id             BIGINT NOT NULL
);

CREATE TABLE mfa_factors (
  factor_id BIGINT PRIMARY KEY, user_id BIGINT, type VARCHAR(16),   -- totp, sms, webauthn
  secret_encrypted BYTEA, phone_e164 VARCHAR(16), created_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ
);

CREATE TABLE linked_identities (
  provider VARCHAR(16), provider_user_id VARCHAR(64), user_id BIGINT,
  PRIMARY KEY (provider, provider_user_id)
);

CREATE TABLE sessions (             -- one row per logged-in device
  session_id          BIGINT PRIMARY KEY,
  user_id             BIGINT NOT NULL,
  device_id           VARCHAR(64),
  device_name         VARCHAR(64),     -- "iPhone 15, Instagram 3xx"
  refresh_token_hash  BYTEA NOT NULL,  -- SHA-256 of the refresh token, never the token itself
  token_family_id     BIGINT,
  created_at TIMESTAMPTZ, last_seen_at TIMESTAMPTZ, last_ip INET, last_geo VARCHAR(64),
  revoked_at TIMESTAMPTZ
);
CREATE INDEX ON sessions (user_id);

Refresh tokens are stored hashed (a fast SHA-256 is fine here because they are long random values, not guessable passwords), so a DB leak doesn't hand out live sessions.

10.2 Username uniqueness needs strong consistency

Two people trying to claim @sunset at the same moment must not both succeed. Most of Instagram can be eventually consistent (like counts, feed), but uniqueness needs a linearizable check-and-set. Approaches:

10.3 Devices and sessions

Each login creates a session row per device. This powers the "Where you're logged in" screen (device name, location, last active) and "Log out of all sessions" (set revoked_at on all rows, bump token_version, push a denylist entry). Assume 1.5B users × ~2 devices ≈ 3B session rows — sharded by user_id alongside the user's other data so "list my sessions" is a single-shard query.

10.4 Login anomaly detection

Every login attempt is scored for risk using signals such as:

The response is graduated: allow; allow and notify ("New login from Chrome on Windows near Berlin — was this you?"); challenge (2FA, email/SMS code, CAPTCHA, approval from a logged-in device); or block. Signals flow through Kafka to the anti-abuse pipeline and ML models; the real-time decision uses features cached in Redis so login stays fast.

10.5 Account recovery

Recovery is the softest target: attackers who can't beat your password simply attack the "forgot password" flow. Good design:

  1. User requests a reset by username/email/phone. The response is identical whether or not the account exists (prevents enumeration). Rate limit per account, per IP, per phone.
  2. Generate a single-use, short-lived (e.g. 15–60 min), high-entropy token; store only its hash; send a link/code to the verified email or phone.
  3. On use: verify hash and expiry, require a new password (checked against breach lists), invalidate all existing sessions and refresh tokens, notify all contact points.
  4. If the attacker changed the email/phone, the old address gets a "revert this change" link valid for days. For hijacked accounts with no access left, offer identity verification (e.g. a video selfie compared with profile photos — Instagram has publicly described using video-selfie verification for account recovery).
  5. Keep 2FA in force: a reset must not silently disable MFA; use backup codes or a stricter process.

11. How Instagram does it: from login to authenticated API calls

📸 In Instagram Instagram's backend started as Django — whose built-in auth framework stores salted, iterated password hashes and uses server-side sessions — and its user data lives in sharded PostgreSQL. Today Instagram supports username/email/phone login, "Continue with Facebook" via Meta's Accounts Center, 2FA through authenticator apps, SMS or WhatsApp, login-activity screens, login alerts and suspicious-login challenges. The exact token formats Meta uses internally are not public; the flow below is a representative, interview-ready design consistent with that behaviour.
📱 Appcredentials
→TLS
Gatewayrate limit login
→
User/Auth Servicehash verify, risk, 2FA
→
User DB + Redissessions
→tokens
📱 AppKeychain/Keystore
  1. Login request. The app sends POST /v1/auth/login {username, password, device_id} over TLS (with certificate pinning on mobile, so a malicious Wi-Fi proxy can't intercept). Payload also carries device signals for risk scoring.
  2. Gateway pre-checks. This endpoint is unauthenticated, so the gateway applies strict rate limits per IP, per device and per username, plus bot detection.
  3. Lookup & verify. The Auth service resolves the username → user_id via the username table, loads the user row from its PostgreSQL shard, and verifies the password hash (constant-time comparison, dummy hashing for unknown users).
  4. Risk evaluation. Known device and usual location → low risk. New device in a new country → challenge.
  5. Second factor. If 2FA is on (or risk is high), respond 200 {"two_factor_required": true, "challenge_id": "…"}; the app asks for the TOTP/SMS code and submits it to POST /v1/auth/2fa.
  6. Session creation. Create a sessions row (device, IP, geo), generate a refresh token (store its hash) and a short-lived signed access token (sub=user_id, did=device, exp=15 min). Cache session metadata in Redis.
  7. Side effects (async). Publish user.logged_in to Kafka → login-alert notification if the device is new, analytics, anti-abuse features.
  8. Client storage. The app stores the refresh token in the iOS Keychain / Android Keystore and keeps the access token in memory.
  9. Authenticated calls. GET /v1/feed with Authorization: Bearer <access>. The gateway verifies the signature locally with cached public keys (no network call), checks the token-version/denylist cache, and forwards X-User-Id: 1234.
  10. Object-level checks. The Feed service returns only posts the user may see (privacy, blocks, close friends); the Like service checks visibility before accepting a like.
  11. Silent refresh. After 15 minutes the gateway returns 401 token_expired; the app calls /v1/auth/token with the refresh token, gets a rotated pair, and retries — invisible to the user.
  12. Logout / revoke. "Log out" deletes the session row and refresh token; "log out of all sessions" or a password change revokes all sessions and bumps token_version, so outstanding access tokens die within minutes at most.

Rough numbers

Assume 500M DAU, 1.2 full logins per user per month (most stay logged in):
  logins ≈ 500M × 1.2 / 30 ≈ 20M/day ≈ 230/s average (peaks + attacks: thousands/s)
Token refreshes: every 15 min of activity; assume 30 min active/day → ~2 refreshes/user/day
  ≈ 1B/day ≈ 11.6k/s → each needs a sessions-table lookup + write (hash of refresh token)
Access-token verifications: every API call ≈ 1M+/s, all done locally at the gateway (no DB).

This is the core argument for short-lived stateless access tokens plus stateful refresh tokens: the hot path (every request) needs no database, while the warm path (refresh, a few times a day per user) gives you a revocation point.

12. Trade-offs & common pitfalls

✅ Good practices

  • Argon2id/bcrypt with per-user salt (+ pepper in a secrets manager)
  • Short-lived access tokens, rotating refresh tokens with reuse detection
  • Asymmetric signing (RS256/ES256/EdDSA) with key IDs and rotation
  • OAuth authorization code + PKCE via the system browser
  • MFA with TOTP/passkeys; step-up auth on risky actions
  • Deny-by-default, centralised object-level authorization helpers

⚠️ Costs & tensions

  • Slow hashing costs CPU and invites DoS without rate limits
  • Stateless tokens are hard to revoke; stateful sessions need a scalable store
  • Security friction (2FA, challenges) vs user experience and conversion
  • Relationship checks on every object add latency and cache-invalidation complexity

Common pitfalls

13. Real-world technologies

TechnologyCategoryWhen to use
Argon2 / bcrypt / scrypt librariesPassword hashingAlways, for password storage
RedisSession store, denylists, login-attempt countersFast stateful auth data with TTLs
Auth0 / Okta / AWS Cognito / KeycloakManaged or open-source identity providers (OIDC/OAuth, MFA, social login)When you'd rather buy (or self-host) than build auth
JWT libraries (jose, PyJWT, nimbus)Token signing/verification, JWKSStateless access tokens
WebAuthn / FIDO2 (passkeys)Phishing-resistant authenticationPasswordless login, strong 2FA
OPA, AWS Cedar, CasbinPolicy engines (RBAC/ABAC)Centralised, testable authorization rules
SpiceDB, OpenFGA (Zanzibar-style)Relationship-based authorizationSocial/sharing permissions at scale

14. Interview corner

How should passwords be stored, and why not just SHA-256?

Store a hash from a deliberately slow, memory-hard password-hashing function — Argon2id (or bcrypt/scrypt) — with a unique random salt per user, optionally an HMAC pepper held in a secrets manager/HSM. SHA-256 is designed to be fast, so GPUs can test billions of guesses per second against leaked hashes; human passwords have low entropy, so dictionaries crack most of them quickly. Salts defeat rainbow tables and make identical passwords hash differently; cost parameters make each guess expensive and can be raised over time by re-hashing on login.

Sessions or JWTs for Instagram?

Both, in different roles. The hot path — millions of requests/second — uses short-lived signed access tokens (JWT-like, ~15 min) verified locally at the gateway with cached public keys, no DB hit. Long-lived refresh tokens are opaque, stored hashed in a sessions table (per device), rotated on each use with reuse detection. That gives cheap validation plus a revocation point: revoking the session stops refreshes, and access tokens expire quickly; a small denylist/token-version check covers urgent revocations like password changes.

Walk through OAuth 2.0 authorization code flow with PKCE for a mobile app.

The app generates a random code_verifier and sends its SHA-256 hash (code_challenge) with an /authorize request opened in the system browser, along with client_id, redirect_uri, scopes and state. The user authenticates and consents at the authorization server, which redirects back to the app with a one-time authorization code. The app verifies state, then POSTs the code plus the original code_verifier to /token. The server checks the verifier hashes to the stored challenge and returns access, refresh (and, with OIDC, ID) tokens. PKCE prevents an app that intercepts the redirect from redeeming the code, since it doesn't know the verifier; public clients can't hold a client secret, so PKCE replaces it.

What's the difference between OAuth 2.0 and OpenID Connect?

OAuth 2.0 is delegated authorization: it issues access tokens that let a client call an API with certain scopes; it says nothing standard about who the user is. OpenID Connect is an identity layer on OAuth: with the openid scope, the provider also issues an ID token (a signed JWT with sub, iss, aud, exp, nonce, and optional profile claims) plus a userinfo endpoint and discovery. "Log in with Facebook/Google" is OIDC (or OIDC-like) authentication; "let this app post on my behalf" is OAuth authorization.

How do you guarantee username uniqueness in a sharded, multi-region system?

Route all claims for a given username to a single authority: a usernames table keyed (and sharded) by normalised username with a unique constraint, in a strongly consistent store with a single leader or consensus (not multi-master). Signup inserts the claim atomically (INSERT … ON CONFLICT), then creates the user row on the user_id shard, with a reservation TTL and cleanup if the second step fails. Availability hints while typing can use caches; only the final claim must be linearizable. Normalise case and Unicode confusables before comparing.

A user reports their account was hijacked via SIM swap. What would you change in the system?

Reduce reliance on SMS: encourage authenticator apps, push approvals on trusted devices and passkeys; treat phone-number changes and SMS-based recovery as high-risk actions requiring step-up verification and cool-off periods; notify all existing contact points and devices on changes with a revert link; use risk signals (recent SIM change signals where carriers provide them, new device, new location) to require stronger proof; on recovery, revoke all sessions and refresh tokens and re-verify 2FA.

15. Check your understanding

An API returns 401 for one request and 403 for another. What does each mean?

401: not authenticated — missing, invalid or expired credentials (e.g. expired access token → refresh and retry). 403: authenticated, but not permitted to perform this action on this resource (e.g. deleting someone else's comment). Many APIs return 404 instead of 403 for private objects to avoid revealing they exist.

Why does a salt not need to be secret, but a pepper does?

The salt's job is uniqueness: it makes each user's hash different, defeating precomputed tables and forcing per-user cracking. That works even if the attacker knows it. The pepper's job is to add a secret the attacker doesn't have if only the database leaks — so it must be stored elsewhere (secrets manager/HSM).

Access tokens live 15 minutes. A user changes their password at 10:00 because their phone was stolen. Without any denylist, until when could the thief keep calling the API? How do you shorten that?

Up to ~10:15 — the remaining lifetime of any access token already issued — but no longer, because the password change revokes the refresh tokens, so the thief can't obtain new access tokens. To make it immediate, bump the user's token_version (checked against a cached value at the gateway) or add the session's token IDs to a Redis denylist with TTL = 15 min.

Can you read the user ID inside a JWT without the signing key? Can you change it?

Yes, you can read it — the payload is just Base64URL-encoded JSON. No, you can't change it undetected — the signature covers the header and payload, so any modification fails verification (as long as the verifier pins the algorithm and checks the signature).

A developer writes GET /v1/stories/{story_id} that returns the story if the caller is logged in. What's the bug and the fix?

IDOR: any logged-in user can fetch any story by ID, including private accounts' and close-friends-only stories, or stories from people who blocked them. Fix: in the Stories service, load the story and run the object-level check — owner? blocked either way? audience close-friends and viewer on the list? author private and viewer an accepted follower? — and return 404 if not allowed.

bcrypt cost 12 takes ~250 ms per hash on one core. Peak login traffic is 4,000 attempts/s. How many cores do you need just for hashing, and what does this imply?

4,000 × 0.25 s = 1,000 core-seconds per second → ~1,000 cores. Implications: login endpoints must be strictly rate limited (otherwise attackers can force you to burn CPU — a DoS vector), consider tuning cost to a target like ~100 ms, and make sure normal API traffic uses cheap token validation, not passwords.

🎯 Key takeaways

Related lessons