Handbooks  /  OAuth & Auth Deep Dive
Handbook~16 min readSecurityworked math + runnable code
The OAuth & Auth Deep Dive

Never hand over
the password.

"Sign in with Google" solves a problem that sounds impossible: let some app act for you — read your calendar, post on your behalf — without ever telling it your Google password. OAuth's answer is a piece of misdirection: the app never gets your credentials, only a short-lived code it swaps for a limited, revocable token. And because a code flying through a browser could be stolen or forged, two small parameters — state and PKCE — stand guard. This handbook is the flow, and the exact reason those two checks make it safe.

01

Delegated access

OAuth 2.0 exists to answer one question: how can I let an app do something on my behalf at another service without giving it my password? Handing over credentials would be a disaster — the app could do anything, forever, and you couldn't revoke it without changing your password everywhere. OAuth replaces that with delegated authorization: you authenticate directly with the provider (Google, GitHub), approve a specific, limited scope, and the provider hands the app a token — a key to one room, not the master key.

The token is scoped (only what you approved — "read calendar," not "delete account"), short-lived, and revocable (you can cut it off without touching your password). That's the whole value proposition: your credentials never leave the provider, and the access you grant is narrow and reversible. But getting that token to the app safely, through the untrusted medium of a browser redirect, is where the real design lives — and where the security is won or lost.

The one-sentence version

OAuth gives an app a scoped, revocable token instead of your password — delivered via a short-lived code that's worthless without a back-channel exchange, guarded by state (blocks forged responses) and PKCE (blocks stolen codes).

02

Who you are vs what you may do

Everything else in this handbook — codes, tokens, scopes, PKCE — is machinery bolted on top of two questions that English keeps collapsing into a single word. Authentication (authn) asks who are you. Authorization (authz) asks what may you do. Both abbreviate to "auth", both get called "the auth layer", and teams pay for that ambiguity for years. The cleanest way to keep them apart is an airport. Passport control compares your face to a document and decides whether you are the person you claim to be; it does not care where you are flying. The boarding pass and the lounge door never re-check your face — they take your identity as settled and decide, per door, whether the holder is entitled through. One gate, one question each.

Authentication is evidence-matching. You present a factor — something you know (a password), something you have (a passkey, a hardware key, a TOTP code), something you are (a fingerprint) — and the system compares it against what it already holds for that account. If the evidence checks out, the request stops being anonymous and becomes a principal: a named subject the rest of the system is willing to believe in. That is the entire output of authentication. Not a role, not a permission, not a green light — just a name. Everything downstream consumes that name; nothing downstream re-derives it, which is precisely why the name has to be carried forward safely, and why the next section exists.

Authentication is also expensive — a password prompt, a biometric, a second factor — so it runs once, at the start of a session, and its result is cached in something the client holds and re-presents. Authorization is cheap and contextual, so it runs on every single action. That asymmetry is the source of most real-world auth bugs: the expensive check is the one everybody remembers to implement, and the cheap one is the one everybody forgets to repeat.

Authorization begins exactly where authentication ends. It takes an established identity and maps it onto permissions — roles (this user is an admin), scopes (this token may read calendars), policies (this user may edit rows they own, during business hours, from a managed device). Two people can both be perfectly authenticated and still get wildly different answers, because authentication only ever confirmed who, and authorization is a second, independent judgement about what. Crucially, authorization is a function of the identity and the resource, not the identity alone: "may Alice read invoices" is the wrong question, and "may Alice read invoice #1043" is the right one.

Now the trap that catches even senior engineers, because it is baked into HTTP itself: the two status codes are named backwards. 401 Unauthorized is the passport gate. Despite the name, RFC 9110 defines it as a request that "lacks valid authentication credentials for the target resource" — it means unauthenticated, go log in — and a conforming 401 response must carry a WWW-Authenticate header telling the client how. 403 Forbidden is the real authorization failure: the server knows exactly who you are, your credentials are fine, and the answer is still no. Re-authenticating will not help, which is why sending 401 for a permission failure produces the maddening login loop where a user signs in successfully and is bounced straight back to the login screen. (A third option is deliberate: when the very existence of a resource is privileged information, returning 404 instead of 403 avoids confirming that invoice #1043 exists at all.)

Authentication (authn)Authorization (authz)
QuestionWho are you?What may you do?
RunsOnce, at loginOn every action, per resource
InputsCredentials — password, passkey, factorIdentity + resource + policy
OutputA principal (a name)Allow or deny
HTTP code401 — really "unauthenticated"403 — really "not permitted"
In OAuth termsOIDC ID tokenAccess token + scopes

The single most expensive consequence of blurring the two has a name: broken object-level authorization (BOLA, historically IDOR), and it has sat at #1 on the OWASP API Security Top 10 for years. The shape is always identical. An endpoint like GET /api/invoices/1043 carefully validates the token — signature, issuer, expiry, all correct — concludes "this is a logged-in user", and returns the invoice. It never asked the only question that mattered: does invoice 1043 belong to this principal? Change the number in the URL and you are reading someone else's data. The endpoint authenticated flawlessly and authorized not at all. The fix is not a smarter token; it is a per-object ownership check on every read and every write, enforced in the query itself where possible (WHERE id = ? AND owner_id = ?) so it cannot be forgotten.

Three sibling mistakes come from the same confusion. Client-side-only checks: hiding the delete button is a UX affordance, not a permission — the endpoint behind it is still open to anyone who types the URL, so authorization must live on the server. Trusting a client-supplied claim: if a request says role=admin in a header, a query parameter, or an unverified token body, that is the attacker's opinion, not a fact; roles are only trustworthy when they arrive inside something the server signed and verified. "Inside the network, therefore trusted": a service that skips authorization because the caller is another service will hand the whole datastore to the first compromised sidecar. See API Design for where these checks belong in a request pipeline, and AI Security for what happens when the caller is an autonomous agent that will happily try every URL it can construct.

The discipline that prevents all of it is short. Authenticate once and establish a principal. Authorize every action, against the specific resource, on the server, at a single choke point you can audit. Default to deny — an unlisted permission is a "no", never an accidental "yes" — and fail closed, so an authorization service that times out blocks the request instead of waving it through. And grant least privilege: give each identity only what it needs, so a stolen credential inherits a small blast radius rather than the keys to everything.

Hold on to that split, because OAuth is unintelligible without it. OAuth 2.0 is an authorization protocol: the access token it issues answers "may this app do X on this user's behalf" and, on its own, does not reliably tell you who the user is. OpenID Connect is the authentication layer added on top, issuing a separate ID token whose entire job is the passport check. The code flow you are about to read is how a scoped permission gets delivered safely; §08 returns to this split once the flow is on the table.

The distinction in one line

Authentication produces a name; authorization decides what that name may touch, per resource, every time. 401 means the server doesn't know you yet; 403 means it knows you and the answer is still no.

▶  Watch it explained

Authentication vs authorization: who you are vs what you can do

03

Carrying identity across a stateless protocol

Authentication gave you a principal. Now comes the problem that shapes every login system ever built: HTTP is stateless. The server reads a request, writes a response, and forgets you completely. The connection may be reused, but the protocol carries no memory — the request after your successful login arrives just as anonymous as the one before it. Something has to re-present your identity on every single request, and the choices you make there decide whether you can revoke access instantly, whether you can scale past one server, and how bad an XSS bug turns out to be.

Clear up the false fight first. A cookie is not an alternative to a token — it is the envelope. The server sends a Set-Cookie header, the browser stores the value and automatically re-attaches it to every matching request. That is the whole job: reliable, automatic delivery. "Cookies vs tokens" compares a container to its contents, and a token can perfectly well be carried inside a cookie. The real axis is: where does the logged-in state actually live — on the server, or inside the thing the user carries?

The stateless problem, and the two answers
Request 1You POST credentials. The server authenticates you and must now issue something to carry that fact forward.
Answer ASession — server stores your data, hands back a meaningless random id. Coat check: they hold the coat, you hold the ticket.
Answer BToken — server writes your identity into a signed blob and stores nothing. Wristband: they check the stamp, not a ledger.
Request 2..nBrowser re-sends it automatically (cookie) or the app attaches it (Authorization: Bearer …).

The cookie is only as safe as its attributes, and those attributes are the most under-used security controls on the web. HttpOnly hides the cookie from document.cookie, so injected JavaScript cannot read your session id and exfiltrate it — it does not stop XSS from making authenticated requests as you, but it stops the attacker from walking away with a portable credential. Secure means the cookie is only ever sent over HTTPS, so it never appears on a plaintext hop (see the TLS handshake lab for what that encryption actually buys). SameSite controls cross-site sending: Strict never sends on a cross-site request, Lax (the modern default) sends only on top-level GET navigations, and None sends always but is rejected unless paired with Secure. Domain and Path scope where it goes — widening Domain to a parent domain hands your cookie to every subdomain, including the forgotten marketing one. And the __Host- name prefix is a free hardening: browsers only accept such a cookie if it is Secure, has no Domain, and has Path=/, which pins it to exactly one origin and blocks subdomain cookie-injection. MDN's Set-Cookie reference is the canonical list.

Automatic attachment is the cookie's superpower and its flaw. Because the browser sends it on requests the attacker's page triggers too, cookie auth is inherently exposed to CSRF — the exact class of attack the state parameter defends against inside OAuth. The modern defence is layered: SameSite=Lax or Strict as the baseline, plus an anti-CSRF token (a synchronizer token, or an origin check) for state-changing requests. A token sent in an Authorization header has the mirror-image profile: it is never attached automatically, so CSRF largely evaporates, but it must be stored somewhere JavaScript can reach — which is exactly what XSS wants.

With a session, the state lives on the server. On login the server writes your account data into its own store and hands back a random, meaningless id, usually in a cookie. Every later request sends the id back; the server looks it up and rehydrates who you are. The coat check is the right picture: the ticket means nothing on its own, and the venue is doing all the remembering. The payoff is control. Revocation is a delete — ban an account, and the very next request finds nothing. You can enumerate active sessions, show a user "signed in on 4 devices", and kill one. The costs are a lookup on every request and, the moment you run more than one server, a shared session store (Redis, or sticky sessions with all their failover pain) so any node can resolve any id — see System Design Fundamentals for why that shared store becomes a availability dependency of your entire login path. Two session hygiene rules are non-negotiable: rotate the id on every privilege change (login, elevation) to kill session fixation, where an attacker plants a known id before you log in and inherits it afterwards; and enforce both an idle timeout and an absolute lifetime.

With a token — in practice a JWT — the state lives inside the token itself. The server writes your identity and permissions into it, signs it, hands it over, and keeps nothing. On each request it verifies its own signature and trusts what it reads. A JWT is three base64url segments joined by dots: header.payload.signature. The header names the algorithm; the payload carries claims — iss (issuer), sub (subject, i.e. the user), aud (audience, the intended recipient), exp/nbf/iat (validity window), jti (a unique id); the signature covers the first two.

Two things about that structure get people badly wrong. First: a signed JWT is not encrypted. Those segments are base64url — encoding, not secrecy — so anyone holding the token can read every claim in it. Signing proves the payload was not altered; it does nothing to keep it private. Never put anything in a JWT you would not print on a postcard. (Encrypted variants exist — JWE — but the default everyone uses is JWS.) Second: verification means verification. Decoding a JWT and reading its claims is not validating it. The historical attacks here are ugly and simple: a token whose header says alg: none and which a naive library accepts unsigned, and the RS256/HS256 confusion attack, where an attacker re-signs a token using the server's public key as an HMAC secret. Both are defeated the same way — pin the expected algorithm and key on the verifying side rather than letting the token's own header choose — and then check signature, iss, aud, and exp on every request. This is the same "validate, don't just decode" rule the pitfalls section repeats for OAuth access tokens, and for the same reason.

Statelessness is what makes tokens scale: any server can verify any token with no lookup and no coordination, which is why they suit horizontally scaled APIs, microservices, and mobile clients. The bill comes due at revocation. The server kept no record, so there is nothing to delete — a stolen or a fired-employee token stays valid until it expires. Every real mitigation reintroduces some state: short access-token lifetimes (minutes, not days) backed by a longer-lived refresh token with rotation and reuse detection; a denylist keyed on jti; or a per-user token version bumped on logout. That is the honest trade — you can have lookup-free scale, or instant revocation, and buying more of one costs some of the other.

Session (state on the server)Token / JWT (state in the token)
What the client holdsA meaningless random idSigned claims about itself
Per-request costA store lookupA signature verification
RevocationDelete the record — instantHard before exp; needs short lifetimes, rotation, or a denylist
ScalingShared session store across nodesNo coordination needed
Reading the contentsServer-side onlyAnyone holding it — it's only signed, not encrypted
Default exposureCSRF (sent automatically)XSS (must be stored somewhere)

Where should a browser app keep one? Prefer an HttpOnly; Secure; SameSite cookie over localStorage: localStorage is readable by any script on the page, so a single XSS hands over a long-lived bearer credential the attacker can replay from their own machine. A cookie does not make you XSS-proof — injected script can still act as you inside the page — but it keeps the credential itself out of the attacker's hands, and it degrades an exfiltration into a session-bound abuse. That is why the pitfalls section ends on the same advice. Everything OAuth hands you later — the access token, the refresh token, the OIDC ID token — is a token in exactly this sense, so every rule here applies to it: verify before you trust, keep lifetimes short, and store it where script cannot read it. For how these headers move over the wire, see Networking.

The question that actually matters

Not "cookie or token" — a cookie is just the envelope. Ask where the state lives: on the server (a session, easy to revoke, needs a shared store) or inside the pass the user carries (a token, scales without lookups, hard to revoke early).

▶  Watch it explained

Cookies vs sessions vs tokens: who remembers you're logged in?

04

Hashing vs encryption vs encoding

The last piece of groundwork is the one developers confuse most often, and the confusion ships real breaches. Encoding, encryption, and hashing all turn readable data into something that looks scrambled, and they get used as loose synonyms for "made safe". They are three different tools doing three different jobs, and one question separates them cleanly: do you need the original back, and who is allowed to get it? Encoding says: anyone. Encryption says: only whoever holds the key. Hashing says: nobody, ever — you can only check whether two things match.

Encoding changes the format of data so it survives a channel that was not built to carry it as-is. Base64 (RFC 4648) maps arbitrary bytes onto a small alphabet of printable characters so binary can ride inside JSON or an email body. UTF-8 encodes characters into bytes so two systems agree on what they are looking at. Percent-encoding escapes spaces and & so a value cannot break the query string it is sitting in. What all of them share is that there is no secret. The rules are public by design, because the receiver has to decode automatically without prior arrangement. Anyone who intercepts a base64 string decodes it in one line, no key needed.

So say it plainly: base64 is not encryption. Base64-encoding an API key and calling it hidden protects nothing at all — it is a costume, not a lock. You have met two live examples already in this handbook. The JWT payload from the previous section is base64url: readable by anyone holding the token, which is exactly why claims must be treated as public. And HTTP Basic authentication sends base64(user:password) — a credential in effectively plain text, which is why Basic auth is meaningless without TLS underneath it.

Encryption is what people usually mean when they say "scrambled and safe". A symmetric cipher like AES (one key encrypts and decrypts, ideally in an authenticated mode such as AES-GCM so tampering is detected as well as blocked) or an asymmetric scheme like RSA or an elliptic-curve equivalent (a public key encrypts, a private key decrypts) turns plaintext into ciphertext that is computationally infeasible to reverse without the key. That gets you confidentiality: the ciphertext can sit on a stolen disk or cross a hostile network and remain useless. Encryption is still reversible — that is a requirement, not a flaw, because the legitimate recipient must read the message back — but reversibility is gated behind a secret. Which relocates the whole problem to key management: lose the key and the data is gone; leak the key and every record protected by it is exposed at once. Hold that thought, because it is exactly why encryption is the wrong tool for passwords.

Hashing takes input of any size and produces a fixed-size digest with an algorithm like SHA-256. There is no key and no "decrypt", because a hash is not a scrambled copy of the input in any recoverable sense — it is a fingerprint. Its useful property is determinism: the same input always yields the same digest, and a good hash makes it infeasible to find an input that produces a chosen digest, or two inputs sharing one. So you never need the original: you recompute and compare. That is how you verify a downloaded file is untampered, how content-addressed storage and Git identify objects, and — with a key mixed in, giving you an HMAC — how a JWT signature proves a token came from your server. You have also already seen it inside OAuth itself: PKCE's challenge = base64url(SHA256(verifier)) from the verification math is both transformations in one line — SHA-256 for one-wayness, base64url purely so the result can ride in a URL.

EncodingEncryptionHashing
Reversible?Yes, by anyoneYes, with the keyNo, by design
Needs a key?NoYesNo (HMAC: yes)
PurposeMake data transportableKeep data confidentialVerify integrity or a match
TypicalBase64, UTF-8, URL-encodingAES-GCM, RSA, ECDHSHA-256, HMAC, bcrypt, Argon2
Use for passwords?Never — no protectionNo — reversible by designYes — slow + salted

That last row is the whole reason this section is in an auth handbook. "We encrypt our passwords" sounds responsible and is a design flaw. Encryption is reversible on purpose, so the key becomes a single point of catastrophic failure: steal it, and every password in the database decrypts at once — and because people reuse passwords, that breach becomes a breach of your users' other accounts too. A password never needs to come back. It only ever needs to be checked, and checking is what hashing does. So: hash the password, store the digest, and on login hash the attempt and compare. The plaintext is never stored and never recoverable — which is also why a correctly built system can only ever reset your password, never email it to you. A site that can tell you your old password has told you something important about its storage.

Plain SHA-256 is not enough here, and the reason is speed. SHA-256 is engineered to be fast, and a GPU rig will compute billions of candidate digests per second, so a stolen table of raw SHA-256 password hashes is cracked at leisure. Passwords need a deliberately slow hash — a password-hashing function with a tunable cost. Argon2id is the current first choice: it is memory-hard, meaning you tune memory as well as time, which blunts the GPU and ASIC advantage that raw compute-bound hashes hand attackers. bcrypt remains a solid, battle-tested option with a cost factor that doubles work per increment — mind its long-standing input truncation at 72 bytes, which matters if you allow passphrases or pre-hash. scrypt and PBKDF2 (the FIPS-friendly choice, but compute-bound, so it needs a high iteration count) round out the acceptable set. Follow the current parameter recommendations in the OWASP Password Storage Cheat Sheet rather than numbers memorised from a blog post — they are revised as hardware gets faster, and cost factors are meant to be raised over time.

Two more properties make the difference between a slow hash and a safe one. A salt — a unique random value per password, stored alongside the digest — means identical passwords produce different digests, which kills precomputed rainbow tables and forces an attacker to attack each account separately instead of the whole table at once. (Argon2 and bcrypt generate and embed salts for you; that is a reason to use them rather than hand-rolling.) A pepper is an optional site-wide secret mixed in and stored outside the database — in a KMS or HSM — so a pure database dump is not enough to start cracking. And when comparing any secret-derived value, use a constant-time comparison (hmac.compare_digest and friends) rather than ==, so response timing does not leak how many bytes matched.

Finally, the nuance that keeps this from becoming "hash everything". The choice follows the requirement, not the vibe. A password does not need to come back, so it gets hashed. An OAuth refresh token, an API key you must present to a third party, or a stored credential your service needs to use does need to come back — so it is encrypted at rest, with a key held in a real key-management service, and it gets short lifetimes and rotation on top. A message crossing the network needs both: encrypted for confidentiality, and integrity-checked so tampering is detected — which is exactly what TLS composes for you (walk it in the TLS handshake lab). Ask what you need back and who may have it, and the right transformation picks itself.

Worth knowing

Encoding is a costume (base64 protects nothing). Encryption is a lock with a key you must protect. Hashing is a one-way fingerprint. Hash passwords with a slow, salted algorithm like Argon2id or bcrypt — never encrypt them, because reversible is the one thing a stored password must never be.

▶  Watch it explained

Hashing vs encryption vs encoding: three things every dev confuses

05

The code flow

The standard, most secure path is the authorization code flow. Its defining trick is that what travels through the browser is not the token — it's a short-lived authorization code that's useless on its own. Turning that code into a token requires a separate back-channel request the browser (and any attacker watching it) can't make.

Authorization code flow (with PKCE)
1 · RedirectApp sends you to the provider with a state + a PKCE challenge.
2 · Log inYou authenticate with the provider and approve the scope. The app never sees this.
3 · Code backProvider redirects to the app with a short-lived code + the echoed state.
4 · ExchangeApp calls the provider back-channel with the code + its secret + PKCE verifier → gets the token.

Two things never appear in a browser URL: your password (you type it into the provider, not the app) and the access token (it's only ever returned over the back channel in step 4). The code does ride through the browser, which is exactly why it must be short-lived, single-use, and — as the next section shows — bound to a secret the attacker can't have. Split the flow into "get a code publicly, redeem it privately" and the browser leg carries nothing an attacker can use alone.

06

State & PKCE

Two parameters defend the two ways the browser leg can be attacked. state is a random value the app generates before the redirect and checks is echoed back unchanged. If the returned state doesn't match what the app sent, the response belongs to a flow the app never started — a forged authorization response — and it's rejected. That's CSRF protection: an attacker can't trick your browser into completing their login into your session, because they can't guess your state.

PKCE (Proof Key for Code Exchange, "pixie") defends against a stolen code. Before redirecting, the app generates a random verifier and sends only its hash — the challenge — to the provider. At exchange time (step 4), the app must present the original verifier; the provider re-hashes it and rejects the exchange unless it matches the challenge it stored. So even if an attacker intercepts the authorization code, they can't redeem it: they'd need the verifier, which never left the app. State proves the response is yours; PKCE proves the redemption comes from the same app that started the flow. Together they make a code that flies through an untrusted browser safe to use.

07

The verification math

PKCE binds the code to a secret via a one-way hash: the challenge is the hash of the verifier, and the exchange re-derives it and compares. A token is issued only if the state matches and the verifier hashes to the challenge:

challenge  =  base64url(SHA256(verifier))     issue token  ⟺  (statesent = stateback) ∧ (SHA256(verifier) = challenge)

The challenge is public (it rode through the browser); the verifier is secret. Because SHA-256 is one-way, seeing the challenge doesn't reveal the verifier — so a stolen code can't be redeemed.

Break either check and no token is issued — a mismatched state (forged response) or a wrong verifier (stolen code without the secret) both fail closed:

statesent ≠ stateback  ⟹  reject (CSRF)     SHA256(verifier) ≠ challenge  ⟹  reject (stolen code)

Two independent gates, both must pass. The runnable version below derives the challenge, checks state, verifies PKCE, and runs the full exchange for valid and attacker cases.

RUN IT YOURSELF

The auth-code exchange, verified

OAuth's authorization-code flow is guarded by two checks. State is a random value echoed back unchanged — if it doesn't match, the response was forged (CSRF) and is rejected. PKCE binds the code to a secret: the app sends up front a challenge = SHA-256 hash of a secret verifier, and at token exchange must present the verifier, which the provider re-hashes and compares. Because the hash is one-way, seeing the public challenge doesn't reveal the verifier — so a stolen code is useless. A token is issued only if state matches AND the verifier hashes to the challenge. Change the verifier or the state and watch the exchange fail closed.

CPython · WebAssembly
08

OAuth vs OIDC — access, then identity

Plain OAuth 2.0 is an authorization protocol — it grants scoped access via tokens; it does not, by itself, tell the app who you are. That's what OpenID Connect (OIDC) adds: a thin identity layer on top of OAuth that issues an ID token (a signed JWT) proving who authenticated. "Sign in with Google" is OIDC — OAuth's access grant plus an identity assertion. Keep the two straight and the ecosystem stops being a soup of acronyms: OAuth hands out access, OIDC adds identity, tokens carry both across requests so the server doesn't have to keep server-side session state. Confuse them and you'll build systems that authenticate but don't authorize (everyone's an admin) or authorize without authenticating (anonymous access to private data) — both classic breaches.

09

Pitfalls

The first is skipping state or PKCE because "it works without them." It does work — right up until someone forges a response or steals a code. Both parameters are cheap and both are now mandatory in the current best-practice (OAuth 2.1 bakes PKCE into every flow). The second is the deprecated implicit flow, which returned the token directly in the browser URL. It exists in old tutorials; don't use it — the authorization-code flow with PKCE replaces it precisely because a token in a URL is a token in browser history, referer headers, and logs.

Three more. Not validating tokens: a server that accepts a token without checking its signature, issuer, audience, and expiry will accept forged or stolen ones — always verify, don't just decode. Wide scopes and no expiry: request the least scope you need and keep access tokens short-lived (use refresh tokens to renew), so a leaked token is both limited and temporary. And leaking tokens: a token is a bearer credential — whoever holds it is you — so treat it like a password: never log it, never put it in a URL, store it carefully (httpOnly cookies over localStorage where you can). Get the flow right — code not token in the browser, state and PKCE always on, tokens validated, scoped, and short-lived — and OAuth does something genuinely elegant: it lets strangers act for you, exactly as much as you allowed, and never a bit more. The whole handbook is that sentence: delegate access, never the password, and guard the code with state and PKCE.

Worth knowing

Use the authorization-code flow with PKCE (never implicit), always send and check state, validate every token's signature/issuer/audience/expiry, request least scope, and keep access tokens short-lived with refresh tokens. Treat a token as a bearer credential — holding it is being you.

Frequently asked

Quick answers

What is OAuth?

A protocol that lets an app act for you at another service via a scoped, revocable token — without ever seeing your password.

What's the code flow?

You log in with the provider, which redirects a short-lived code to the app; the app exchanges it back-channel for a token. The token never touches a browser URL.

What do state and PKCE do?

State blocks forged (CSRF) responses by echoing a random value; PKCE binds the code to a secret verifier so a stolen code can't be redeemed.

Authn vs authz?

Authentication proves who you are; authorization decides what you may do. OAuth is authz; OIDC adds authn with an ID token.

What's the difference between 401 and 403?

Both names mislead. 401 "Unauthorized" actually means unauthenticated — the server doesn't know who you are yet, so log in. 403 "Forbidden" is the real authorization failure: your identity is fine and the answer is still no, so re-authenticating won't help.

Why isn't a valid token enough to authorize a request?

A valid token only proves identity. If the endpoint doesn't also check that this principal owns this object, changing the id in the URL reads someone else's data — broken object-level authorization, #1 on the OWASP API Security Top 10.

Cookies vs sessions vs tokens?

A cookie is only the envelope; the real question is where the login state lives. A session keeps it on the server (easy to revoke, needs a shared store); a token like a JWT carries it inside itself (scales without lookups, hard to revoke before it expires).

Is a JWT encrypted?

No. A standard signed JWT is three base64url segments — signing proves the claims weren't altered, it does not hide them. Anyone holding the token can read the payload, so never put secrets in it, and always verify the signature, issuer, audience and expiry rather than just decoding.

Why hash passwords instead of encrypting them?

Encryption is reversible by design, so one stolen key decrypts every password at once. A password never needs to come back — only to be checked — so hash it with a slow, salted algorithm like Argon2id or bcrypt, not with encryption and not with plain SHA-256.

Is base64 a form of encryption?

No — base64 is encoding, reversible by anyone with no key at all. It exists to make bytes transportable, not private. That's why HTTP Basic auth needs TLS, and why a base64 JWT payload is effectively public.

▶  Watch it explained

OAuth: how "Sign in with Google" works without your password

Finished this one? 0 / 208 Handbooks done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Handbooks