1. Password Hygiene & Hashing
Week 4 established that hashing is one-way. Passwords need more than a plain hash, though — a fast, general-purpose hash like SHA-256 is actually the wrong tool here, because it's designed to be fast, and an attacker with a stolen database of hashes can compute billions of guesses per second on modern hardware.
# SHA-256: designed to be FAST -- great for file integrity, bad for passwords
# bcrypt / Argon2: designed to be SLOW and memory-hard, deliberately, so
# brute-forcing many guesses costs an attacker real time and hardware
$ htpasswd -bnBC 12 "" 'correct horse battery staple' | tr -d ':\n'
$2y$12$KIXQ... # bcrypt hash -- notice the embedded cost factor (12) and a random salt
The embedded, per-password salt matters as much as the algorithm: two users with the identical password get completely different hashes, which defeats precomputed rainbow table attacks that work by matching stolen hashes against a huge precomputed dictionary. Salting is why "the same password hashes to the same value everywhere" is no longer true once it's done correctly.
Forced complexity ("must contain a symbol, a number, an uppercase letter") pushes people toward predictable substitutions (Password1!) that don't actually resist a targeted guess. A long passphrase resists brute force far better and is easier for a human to actually remember — current NIST guidance favors length and screening against known-breached password lists over arbitrary complexity rules.
2. MFA, and Why Password Resets Are an Attack Surface Too
Multi-factor authentication requires something beyond "something you know" (a password) — usually "something you have" (a phone, a hardware key) or "something you are" (biometrics). It's the single highest-leverage control against credential-stuffing and phishing, because a stolen password alone stops being enough.
SMS codes # weakest -- vulnerable to SIM-swapping and interception
Authenticator apps # better -- TOTP codes, not interceptable over the phone network
Push notifications # convenient, but vulnerable to "MFA fatigue" (approve-spamming)
Hardware keys (FIDO2/WebAuthn) # strongest -- cryptographically bound to the real site,
# can't be phished onto a look-alike domain at all
The password-reset flow deserves the same scrutiny as login itself — it's often the weaker path into an account, precisely because it's designed to work when someone claims they've lost their normal credentials, which is exactly the situation an attacker impersonates.
# Weak: a predictable or short-lived-but-guessable reset token,
# or a "security question" whose answer is publicly findable
# Better:
1. Reset link uses a long, cryptographically random, single-use token
2. Token expires quickly (15-30 minutes)
3. Successful reset invalidates ALL existing sessions for that account
4. An email notification fires on any reset, to the account's real address,
regardless of whether the reset succeeded -- so the real owner knows
5. Reset does NOT bypass MFA if MFA is enabled on the account
A password reset that skips MFA "for convenience, since they're already proving account ownership via email" is a real, common flaw — it means anyone who compromises the email account (a much softer target, often protected by a weaker password) can bypass MFA entirely by triggering a reset. The reset flow needs to be at least as strong as the login flow it's replacing.
3. OAuth 2.0 & OpenID Connect
OAuth 2.0 is an authorization framework — it lets a user grant one application limited access to their data on another, without ever sharing their password with the requesting app. "Sign in with Google" uses it, but its actual job is authorization ("can this app read your calendar?"), not authentication on its own.
1. User clicks "Connect Google Calendar" on YourApp
2. YourApp redirects to Google's login/consent screen
3. User logs into Google (if not already) and approves the specific scopes requested
4. Google redirects back to YourApp with a short-lived AUTHORIZATION CODE
5. YourApp's SERVER exchanges that code (plus its own client secret) for an ACCESS TOKEN
-- this exchange happens server-to-server, so the token never touches the browser
6. YourApp uses the access token to call Google's API, scoped to only what was approved
OpenID Connect (OIDC) is a thin, standardized identity layer built on top of OAuth 2.0 specifically for authentication — it adds a well-defined ID token (a JWT, Section 4) that says "this user is who they claim to be," distinct from OAuth's access token, which says "this app can access this specific resource." Mixing the two up is a common source of real vulnerabilities.
An access token proves "this bearer can call this API with these permissions" — it says nothing definitive about who the user is. Using an OAuth access token as if it were an authentication credential (rather than OIDC's purpose-built ID token) is a classic implementation mistake, and exactly the kind of confusion this section exists to prevent.
4. Reading a JWT Without Trusting It Blindly
A JWT (JSON Web Token) is three base64url-encoded parts, separated by dots — a header, a payload of claims, and a signature. It's not encrypted (Week 4's distinction matters here): anyone can decode and read the payload. What the signature guarantees is that it hasn't been tampered with and was issued by whoever holds the signing key — but only if the verifier actually checks the signature.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0Iiwicm9sZSI6InVzZXIifQ.4a7bC...
# Header (base64url): {"alg":"HS256"}
# Payload (base64url): {"sub":"1234","role":"user"} <- readable by ANYONE, unencrypted
# Signature: proves it wasn't modified, IF the verifier checks it
$ echo 'eyJzdWIiOiIxMjM0Iiwicm9sZSI6InVzZXIifQ' | base64 -d
{"sub":"1234","role":"user"}
This leads to two real, common vulnerability classes. First: an attacker can decode
and read a JWT's payload trivially, so it must never contain a secret. Second, and far
more dangerous: an alg: none or algorithm-confusion attack, where a
server that doesn't strictly validate the signature algorithm can be tricked into
accepting a token an attacker forged themselves.
# NEVER trust a JWT's claims without verifying the signature server-side,
# against a known key, with an explicitly allow-listed algorithm.
# WRONG (pseudocode): decode the payload and trust it
const claims = base64Decode(token.split('.')[1]);
if (claims.role === 'admin') { /* DANGEROUS -- never verified the signature */ }
# RIGHT: verify first, using a library, with the algorithm pinned
const claims = jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] });
// only reachable if the signature genuinely checks out
Some vulnerable implementations read the algorithm from the token itself and verify accordingly — which lets an attacker submit a token claiming alg: none or swap an asymmetric algorithm for a symmetric one using the public key (which isn't secret) as the symmetric key. Always tell the verification library exactly which algorithm(s) are acceptable; never let the token dictate how it should be checked.
5. RBAC vs. ABAC & Designing Least-Privilege Access
Once you know who someone is, access control decides what they can do. Role-Based Access Control (RBAC) assigns permissions to roles, and roles to users — simple, auditable, and the right default for most systems.
Role: editor -> permissions: [read:posts, write:posts]
Role: admin -> permissions: [read:posts, write:posts, delete:posts, manage:users]
User: alice -> role: editor # alice can read and write posts, nothing more
User: bob -> role: admin
RBAC breaks down when access genuinely depends on context RBAC's static roles can't express — "an editor can edit their own posts, but not someone else's" isn't a role, it's a relationship between the user and the specific resource. Attribute-Based Access Control (ABAC) evaluates rules against attributes of the user, the resource, and the environment at request time:
ALLOW edit(post) IF
user.role == 'editor' AND
post.author_id == user.id AND
request.time BETWEEN post.created_at AND post.created_at + 24h # edit window
# RBAC can't express "and it's their own post, and within 24 hours" as a role --
# ABAC evaluates the actual attributes present at request time instead
Most real systems use both: RBAC for the coarse "what kind of user is this" grouping, layered with narrower ABAC-style checks (ownership, time windows, resource state) for the cases RBAC alone can't express — not a strict either/or choice.
The single most common access-control vulnerability isn't a missing role model — it's checking permissions once (at login, or only in the UI) and then trusting a client-sent value on later requests. Every request that touches a protected resource needs its own server-side authorization check, regardless of what the UI shows or hides. This is Week 7's "broken access control" in the OWASP Top 10, in preview.
6. Hands-on Exercise
Hash passwords properly, decode and attack a weak JWT, then design an access model
Put password hashing, JWT verification, and RBAC/ABAC design into practice on a small hypothetical app.
Part 1 — Password hashing:
- Using a language/library of your choice (Python's
bcrypt, Node'sbcryptjs, or thehtpasswdCLI), hash the same password twice and confirm the two resulting hashes are different — explain why, referencing salting. - Write a small script that hashes a password and then verifies a correct and an incorrect attempt against it, confirming only the correct one succeeds.
- Time-compare hashing the same password with a low cost factor (e.g. bcrypt cost 4) versus a higher one (cost 12) — note the real time difference and explain, in one paragraph, why that deliberate slowness is the actual security property being bought.
Part 2 — Decode, then break a weak JWT verifier:
- Take any JWT (generate one at
jwt.ioor with a library) and decode its header and payload by hand using base64 decoding — no library, just to prove to yourself it's genuinely readable without a key. - Write a small server-side snippet (pseudocode is fine, or a real script) that decodes a JWT's claims without verifying the signature, and use it to "log in as admin" by handing it a token with
role: adminthat you crafted yourself — no valid signature required, because it was never checked. - Fix it: rewrite the same snippet using a real JWT library's
verifyfunction with the algorithm explicitly pinned, and confirm your forged token is now rejected.
The point of Part 2, step 2 is to make the vulnerability real and visible before fixing it — if it feels almost too easy to forge a token that's never verified, that's the actual lesson: unverified decoding and cryptographic verification look similar in code but are nothing alike in what they guarantee.
Part 3 — Design an access model:
You're building a small project-management tool: viewer, editor and admin roles, with tasks that belong to a project.
- Define an RBAC table (roles → permissions) for at least four actions: view tasks, create tasks, edit any task, delete a project.
- Identify one rule RBAC alone can't express for this app — e.g. "an editor can edit tasks they created, but not tasks created by others" — and write it as an ABAC-style rule (Section 5's format).
- Write, for each of your four RBAC actions, exactly where the server-side authorization check needs to live (which endpoint, checked against which piece of request data) — not just which role is "supposed to" be allowed.
If your access model only lives in the frontend (hiding a "Delete" button for non-admins) without a matching backend check on the delete endpoint itself, it isn't access control — it's a UI convenience an attacker can bypass by calling the API directly. Every rule from Part 3 should map to a real server-side check.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why is bcrypt/Argon2 the right choice for hashing passwords, while SHA-256 is the wrong choice, even though both are legitimate cryptographic hash functions?
Why is bcrypt/Argon2 the right choice for hashing passwords, while SHA-256 is the wrong choice, even though both are legitimate cryptographic hash functions?
SHA-256 is designed to be fast, which is exactly the wrong property for password hashing — a fast hash lets an attacker with a stolen database compute billions of guesses per second. bcrypt and Argon2 are deliberately slow and memory-hard, making brute-forcing many guesses computationally expensive even with modern hardware, while still being fast enough for the single verification a real login needs.
Q2
Why does a secure password-reset flow need to invalidate all existing sessions and not skip MFA?
Why does a secure password-reset flow need to invalidate all existing sessions and not skip MFA?
If an attacker triggers a password reset (e.g. after compromising the account's email), invalidating existing sessions kicks out any session the real owner still had open, limiting exploitation. Skipping MFA on reset would turn "compromise the email account" into a complete bypass of MFA on the primary account — the reset flow needs to be at least as strong as the login flow it can replace.
Q3
A JWT's payload contains {"role": "admin"}. Is it safe to store a secret value in a JWT payload, as long as the token is signed?
A JWT's payload contains {"role": "admin"}. Is it safe to store a secret value in a JWT payload, as long as the token is signed?
No. A JWT's payload is base64url-encoded, not encrypted — anyone who has the token (which is often sent in headers or stored in the browser) can decode and read it trivially. Signing proves the token wasn't tampered with and came from the legitimate issuer; it does nothing to hide the payload's contents. Secrets never belong in a JWT payload, signed or not.
Q4
"An editor can edit tasks they created, but not tasks created by other editors." Why doesn't a plain RBAC role assignment express this rule on its own?
"An editor can edit tasks they created, but not tasks created by other editors." Why doesn't a plain RBAC role assignment express this rule on its own?
RBAC assigns permissions to a role as a whole ("editors can edit tasks"), independent of which specific resource is involved. This rule depends on a relationship between the specific user and the specific resource — who created it — which is context evaluated at request time, not a static role assignment. That's exactly the gap ABAC-style rules (or an RBAC role combined with an ownership check) fill in.