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.
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.
- Authentication (AuthN): establishing who you are. The ID check at the desk
- Authorization (AuthZ): deciding what you may do. The set of doors your badge opens
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 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 possible characters and length , there are candidates; expressed as a power of two, that's the entropy.
In words: every extra character buys you more bits. Lowercase only () is about 4.7 bits per character; adding symbols to reach 95 gets you about 6.6. What matters is the asymmetry — length multiplies, while the alphabet size only appears inside a logarithm. Adding one symbol buys a few bits; adding four characters buys more than twenty. Since the work is , 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 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: assumes all 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."
Comments
Sign in to comment