JA EN
LearnSecurity
·★ MEMBER·14 min read

Authentication and Authorization — From Passwords to OAuth and Passkeys

"Who are you?" and "what are you allowed to do?" are two different questions. This piece builds up password storage, sessions and tokens, the four actors in OAuth, and why passkeys resist phishing — assuming nothing to start with.

ModalitytextTasksystems

The front desk and the door lock are different things

Picture walking into an office building. First you show ID at the front desk and someone confirms you are who you claim to be. Then you're handed a badge, and that badge decides which doors open and which don't. Two people in the same building — one can get into the finance room, the other can't.

Those two steps are the two terms.

The names are close enough to blur together, and blurring them produces real incidents. A system where "any logged-in user can hit any URL" hands a master key to everyone who cleared the front desk. A large share of real-world breaches aren't broken authentication at all — they're a properly authenticated user reaching data that was never theirs, which is a hole on the authorization side.

What follows builds both up from the bottom. The only prerequisites are the three cryptographic tools — symmetric keys, public keys, and hashes — covered in Cryptography from Scratch.

HTTP does not remember you

One thing first: HTTP has no notion of "I am currently in a conversation with this person." Requests are independent, and from the server's point of view every one of them is a first meeting. As The Evolution of HTTP describes, that statelessness is exactly what makes it good at handling enormous numbers of connections — and exactly what makes authentication awkward.

So logging in can't be a one-time check. You have to turn "this person was verified" into something portable that rides along with every subsequent request. What that something should be is the session ID, the token, and the second half of this article.

Passwords: the hard part is storage, not comparison

Comparison itself is trivial — does the submitted string match the registered one? The real question is how you keep that registered string in the first place.

Storing it as-is makes comparison easy and makes the day of a breach total. And the damage doesn't stop at your own service. People reuse passwords, so a leaked table gets replayed against everyone else's login form (this is credential stuffing).

The fix is to switch from keeping the key to keeping only the shape of the lock that key fits. That's a hash: cheap to compute in one direction, infeasible to reverse. At login you hash the submitted value the same way and compare against what you stored.

That alone isn't enough. A hash is a fixed function, so password123 produces the same digest everywhere on earth. An attacker can precompute an enormous "common password → digest" table and simply look up your leaked rows.

The countermeasure is a salt: a random string generated per user, mixed into the password before hashing. The salt is stored alongside the digest — it doesn't need to be secret. Now two users with the same password get different stored values, and precomputed tables are worthless. The attacker has to start over for every single user.

There's one more layer. Hash functions like SHA-256 are sold on being fast, and fast helps the attacker too. So for passwords you deliberately use a function that is slow and memory-hungry on purpose: bcrypt, scrypt, Argon2. Each exposes a cost parameter controlling how expensive it is — a bcrypt cost of 10 means 2102^{10} rounds of internal key setup. When hardware gets faster, you raise the number.

import os, hmac, hashlib

def register(password: str):
    salt = os.urandom(16)                            # unique per user
    h = hashlib.scrypt(password.encode(), salt=salt,
                       n=2**15, r=8, p=1)            # slow and memory-hungry on purpose
    return salt, h                                   # store both

def verify(password: str, salt: bytes, stored: bytes) -> bool:
    h = hashlib.scrypt(password.encode(), salt=salt, n=2**15, r=8, p=1)
    return hmac.compare_digest(h, stored)            # constant-time comparison

That final compare_digest matters. An ordinary == walks byte by byte and stops at the first mismatch, which means the more bytes that matched, the longer it took. The difference is microseconds, but measured tens of thousands of times it becomes statistically visible, and an attacker can recover the value one byte at a time. Constant-time comparison always reads to the end, closing that leak.

So how strong is a password itself? With NN possible characters and length LL, there are NLN^L candidates; expressed as a power of two, that's the entropy.

H=Llog2NH = L \log_2 N
(1)

In words: every extra character buys you log2N\log_2 N more bits. Lowercase only (N=26N = 26) is about 4.7 bits per character; adding symbols to reach 95 gets you about 6.6. What matters is the asymmetry — length LL multiplies, while the alphabet size NN only appears inside a logarithm. Adding one symbol buys a few bits; adding four characters buys more than twenty. Since the work is 2H2^H, twenty bits is a factor of a million. That formula is the whole reason guidance shifted from "must contain a digit and a symbol" to "make it longer."

But 2H2^H is only a meaningful measure of safety when the attacker can grind hashes locally. An online attack — hammering the login form — pays a network round trip per guess and tops out around tens of attempts per second, so what protects you there isn't entropy but rate limiting. An offline attack — grinding a stolen table on the attacker's own hardware — has no network and no rate limiter in the way, so the speed is whatever the silicon can do. That is the only place where entropy and the hash cost parameter are your sole defense.

On the online side, be careful how you throttle. "Lock the account after five failures" looks sensible until you notice an attacker can fail on purpose to lock other people out. In practice teams prefer escalating delays over hard lockouts, combined with per-IP limits. And fundamentally, online guessing stops mattering once multi-factor authentication is in place, so don't try to carry the whole defense on rate limiting alone.

Two more storage-adjacent mistakes worth naming: logging the password before verification, and silently truncating overly long input. The first is a plaintext leak by definition; the second quietly leaves users protected by a shorter password than they think they chose. Accept the input uncut, and let the pre-hash value exist nowhere.

One caveat on the math: NLN^L assumes all LL characters were chosen at random. Something a human invents, like P@ssw0rd2026!, scores well on the formula, but attacker dictionaries already encode the usual mutations (a→@, a year on the end), so its effective strength is far below what the formula claims. That's why current guidance is less "enforce complexity rules" and more "require real length, then reject anything appearing in known-breach lists."

FIG 1Switch the vertical axis to logarithmic and the point lands: password length grows by addition while the guessing work grows by exponent. Making the hash one step heavier (raising bcrypt's cost by 1) shifts this curve sideways by one

The most straightforward way to carry "verified" forward is to have the server keep a ledger. On successful login it issues a sufficiently long random string — the session ID — writes a row saying "this ID = user 42" into its store (memory, Redis), and hands the ID to the browser in a cookie. The browser sends it autom

What's behind this

§

Members-only from here

371 walkthroughs, 26 textbook chapters, 48 student units and 6 close readings — all included for $4.99/mo, with three new explainers every day. Cancel any time; access runs to the end of the period.

Already a member? Sign in to keep reading

Comments

Sign in to comment