- Authentication (AuthN) proves who you are; authorization (AuthZ) decides what you may do. Instagram does both on every request.
- Passwords are never stored — only a slow, salted hash (bcrypt, scrypt or Argon2), optionally with a secret pepper.
- After login a client holds either a session ID (looked up in a store like Redis) or a signed token such as a JWT (validated statelessly). Short-lived access tokens + long-lived, rotating refresh tokens give the best of both.
- OAuth 2.0 (with PKCE for mobile) delegates access; OpenID Connect adds identity on top — that's how "Log in with Facebook" works. MFA adds a second factor (prefer TOTP/passkeys over SMS).
- The gateway validates identity; each service enforces object-level permissions (private accounts, blocks, close friends) — skipping that causes IDOR vulnerabilities.
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:
- Who is making this request? Is it really @alice on her phone, or an attacker who stole her password, or a bot?
- 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.
2. Authentication vs authorization
| Authentication (AuthN) | Authorization (AuthZ) | |
|---|---|---|
| Question | "Who are you?" | "What are you allowed to do?" |
| Input | Credentials: password, OTP code, passkey, social login, a token | An authenticated identity + the action + the resource + context |
| Output | A verified identity (user ID), usually materialised as a session or token | Allow / deny (sometimes "allow with filters") |
| Frequency | Full login rarely; token check on every request | On every request and often for every object returned |
| Failure status | 401 Unauthorized (confusingly named — it means unauthenticated) | 403 Forbidden (or 404 to avoid revealing existence) |
| Instagram example | Logging in with username + password + 2FA code | Hiding 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:
- Two users with password "sunshine1" have completely different hashes.
- Precomputed tables are useless — they'd have to be rebuilt for every salt.
- An attacker must attack each user's hash separately.
Salts are not secret. Their job is uniqueness, not secrecy.
3.3 Slow, memory-hard password hashing: bcrypt, scrypt, Argon2
| Algorithm | Key idea | Tunable parameters | Notes |
|---|---|---|---|
| bcrypt (1999) | Deliberately expensive key setup based on Blowfish; built-in salt | Cost 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 expensive | N (CPU/memory cost), r, p | Good choice; used in some cryptocurrencies |
| Argon2id (2015) | Winner of the Password Hashing Competition; memory-hard and side-channel resistant | Memory (e.g. 64 MiB), iterations, parallelism | Current 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
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)
- On login, the server generates a long random session ID (e.g. 128+ bits from a CSPRNG).
- 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. - 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. - The server looks up the ID on each request. Found and unexpired → authenticated as that user.
- 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.
- Header — algorithm (
alg) and key ID (kid, so verifiers know which public key to use during key rotation). - Payload — claims:
sub(subject = user ID),iss(issuer),aud(intended audience),exp(expiry),iat(issued at),jti(unique token ID), plus custom claims like device ID or scopes. Anyone can decode it — never put secrets or personal data in it. - Signature — computed over
base64url(header) + "." + base64url(payload). Changing a single character of the payload invalidates it.
Signing: HS256 vs RS256 (and ES256/EdDSA)
| HS256 (HMAC + SHA-256) | RS256 (RSA signature + SHA-256) | |
|---|---|---|
| Key type | Symmetric: one shared secret signs and verifies | Asymmetric: private key signs, public key verifies |
| Who can verify | Anyone with the secret — who can therefore also forge tokens | Anyone with the public key — they cannot forge |
| Speed | Very fast | Verify fast, sign slower; tokens bigger (256-byte signature for 2048-bit keys) |
| Key distribution | Secret must be copied to every verifier — larger blast radius if leaked | Public keys published (e.g. a JWKS endpoint); only the auth service holds the private key |
| Best for | A single service that both issues and verifies | Many 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:
- Short expiry (5–15 minutes): a stolen token is useful only briefly. This is why access tokens are short-lived.
- Denylist: store revoked
jtis (or "tokens for user X issued before time T are invalid") in Redis, checked on each request. Small, because entries only need to live until the token would have expired anyway. But now you've reintroduced a lookup. - Token version per user: put
verin the token; bump the user's version on password change; services compare against a cached value. - Refresh-token revocation: revoke the long-lived refresh token in the database; the client can't get new access tokens, so access ends within one access-token lifetime.
4.3 Sessions vs JWTs compared
| Opaque session ID + store | JWT (self-contained) | |
|---|---|---|
| Server state | Session store required | None for validation |
| Per-request cost | Network lookup (~0.5 ms) | Local signature check (~0.05–0.2 ms) |
| Revocation | Instant — delete the session | Hard — wait for expiry or maintain a denylist |
| Cross-service | Every service needs store access (or gateway translates) | Any service with the public key can verify |
| Data freshness | Always current | Claims 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 token | Refresh token | |
|---|---|---|
| Purpose | Sent on every API call | Used only to obtain new access tokens |
| Lifetime | Short: minutes to an hour | Long: weeks to months (sliding) |
| Format | Often JWT (stateless check) | Opaque random string, stored hashed in the DB (stateful, revocable) |
| Sent to | Every API endpoint via the gateway | Only the auth service's /token endpoint |
| Stored on device | Memory | Secure 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:
- The app generates a random
code_verifier(43–128 chars) and keeps it secret in memory. - It sends
code_challenge = BASE64URL(SHA256(code_verifier))with the authorization request. - 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:
- 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.
- Facebook returns a code/token proving identity for a Facebook user ID, with consented scopes.
- 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. - 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
- SIM swapping: an attacker convinces the carrier to move your number to their SIM, then receives your codes. A notorious way high-value Instagram handles have been stolen.
- SS7 interception and malware reading SMS.
- Phishing: users type the code into a fake site in real time.
- Cost and abuse: each SMS costs money; attackers trigger mass SMS to premium numbers ("SMS pumping fraud") — rate limit hard.
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:
| Rule | Check | Data needed |
|---|---|---|
| Ownership | Only the author can edit/delete a post; post owner or comment author can delete a comment | post.author_id == viewer_id |
| Private accounts | Posts/stories visible only to approved followers | Account privacy flag + follow edge (status = accepted) from the graph store |
| Blocking | If either user blocked the other: can't see profile, posts, comments; can't DM, follow or mention | Block edges in both directions |
| Close Friends | A close-friends story is visible only to users on the author's list | Author'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
- Gateway: validates the access token (signature, expiry, audience), rejects with 401, checks coarse scopes for third-party apps, strips spoofed identity headers and forwards a trusted identity. See API Gateway.
- Services: enforce object-level permissions using their own data plus graph lookups. They should accept identity only from the gateway/mesh (mTLS), never from client-supplied headers.
- Service-to-service: services authenticate each other (mTLS identities in a service mesh) and propagate the end-user context so downstream services can still apply per-user rules.
- Async consumers: e.g. the feed fan-out workers and notification service must also respect blocks and privacy — a fan-out to a blocked user's timeline is a privacy bug too. Re-check at read time as a safety net.
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:
- Unique constraint in one authoritative place: the
usernamestable keyed by normalised username (lower-case, Unicode-normalised, confusable characters handled). Signup doesINSERT … ON CONFLICT DO NOTHINGin a transaction; zero rows inserted → "username taken". If the user table is sharded byuser_id, the username index must be its own table sharded by username (so the uniqueness check lands on one shard) — or a separate strongly consistent store. - Reserve-then-commit: claim the username in the uniqueness table with a short TTL, create the user row on its shard, then confirm the claim. A cleanup job releases claims whose user creation failed. (A two-step "saga" instead of a distributed transaction.)
- Multi-region: uniqueness must be decided by a single leader/quorum (e.g. a consensus-based store like Spanner, CockroachDB, or a primary region for the username table) — two regions accepting writes independently would allow duplicates.
- Availability check vs claim: the "is this available?" check while typing can hit a cache or read replica (may be slightly stale); only the final claim must be strongly consistent.
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:
- New device or app install vs a known
device_id; new country/ASN; "impossible travel" (Mumbai, then São Paulo 20 minutes later). - IP reputation (data-center/proxy IPs, IPs seen in credential-stuffing waves), many accounts tried from one IP, many IPs trying one account.
- Password appears in known breach corpuses; behavioural/device-integrity signals.
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:
- 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.
- 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.
- 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.
- 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).
- 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
- 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. - Gateway pre-checks. This endpoint is unauthenticated, so the gateway applies strict rate limits per IP, per device and per username, plus bot detection.
- Lookup & verify. The Auth service resolves the username →
user_idvia the username table, loads the user row from its PostgreSQL shard, and verifies the password hash (constant-time comparison, dummy hashing for unknown users). - Risk evaluation. Known device and usual location → low risk. New device in a new country → challenge.
- 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 toPOST /v1/auth/2fa. - Session creation. Create a
sessionsrow (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. - Side effects (async). Publish
user.logged_into Kafka → login-alert notification if the device is new, analytics, anti-abuse features. - Client storage. The app stores the refresh token in the iOS Keychain / Android Keystore and keeps the access token in memory.
- Authenticated calls.
GET /v1/feedwithAuthorization: Bearer <access>. The gateway verifies the signature locally with cached public keys (no network call), checks the token-version/denylist cache, and forwardsX-User-Id: 1234. - 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.
- Silent refresh. After 15 minutes the gateway returns
401 token_expired; the app calls/v1/auth/tokenwith the refresh token, gets a rotated pair, and retries — invisible to the user. - 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
- Fast hashes (MD5, SHA-1, SHA-256) or unsalted hashes for passwords.
- Putting sensitive data in JWT payloads — they're only encoded, not encrypted.
- Not pinning the JWT algorithm (
alg: none, RS→HS confusion) or not checkingaud/iss/exp. - Storing tokens in browser
localStorage— readable by any XSS; prefer HttpOnly, Secure, SameSite cookies on web (and then defend against CSRF). - Authorization only at the gateway → IDOR. Check every object.
- User enumeration via different error messages ("no such user" vs "wrong password") or timing.
- Forgetting async paths — notifications or fan-out leaking content to blocked users.
13. Real-world technologies
| Technology | Category | When to use |
|---|---|---|
| Argon2 / bcrypt / scrypt libraries | Password hashing | Always, for password storage |
| Redis | Session store, denylists, login-attempt counters | Fast stateful auth data with TTLs |
| Auth0 / Okta / AWS Cognito / Keycloak | Managed 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, JWKS | Stateless access tokens |
| WebAuthn / FIDO2 (passkeys) | Phishing-resistant authentication | Passwordless login, strong 2FA |
| OPA, AWS Cedar, Casbin | Policy engines (RBAC/ABAC) | Centralised, testable authorization rules |
| SpiceDB, OpenFGA (Zanzibar-style) | Relationship-based authorization | Social/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.
- AuthN = who (401), AuthZ = what (403/404). The gateway does identity; services do object-level permission checks.
- Store passwords with Argon2id/bcrypt + salt (+ pepper); never fast hashes.
- Use short-lived stateless access tokens + stateful, rotating refresh tokens to balance speed and revocability.
- OAuth 2.0 + PKCE for delegated/mobile flows, OIDC for identity and SSO; prefer TOTP/passkeys over SMS for MFA.
- Instagram's real authorization is relationship-based — private accounts, blocks, close friends — and must be enforced on every path to avoid IDOR.
Related lessons
- API Gateway — where tokens are validated on every request
- Rate Limiting — protecting login, OTP and recovery endpoints
- Security & Privacy — TLS, encryption, abuse and privacy controls
- Social Graph & Follow Service — the follow/block edges behind permission checks
- CAP Theorem & Consistency — why username claims need strong consistency
- Sharding — how the User DB and username index are partitioned
- Caching (Redis) — session stores, denylists and permission caches
- API Design — status codes, auth headers and secure API conventions