CyberNugget

L21 · Authentication vs Authorization, and MFA

Module 4: Identity and Access · ~20 min · Lesson 21 of 71

🎯 What you'll learn

  • The difference between authentication and authorization, and which one a gateway such as Kong does for you
  • The three kinds of authentication factor, and why two of the same kind isn't MFA
  • Why SMS, authenticator-app codes and push prompts can be phished, and why FIDO2 and passkeys can't
  • What an MFA fatigue attack is, and what number matching does (and doesn't) fix

🔥 Why this matters

In July 2022 Microsoft described a phishing campaign that had targeted more than 10,000 organizations since September 2021. Victims had MFA turned on. They typed their password and their one-time code into a fake sign-in page that passed everything to the real one, and the attackers kept the signed-in session. In some cases payment fraud started five minutes later. MFA was on; the kind of MFA was the problem.

1. Authentication vs authorization

At the office, the lobby guard checks your badge photo against your face. That proves who you are. Whether the badge then opens the 12th floor is a separate decision, made by a different system.

Authentication (often written authn) is proving who a caller is. Authorization (authz) is deciding what that proven caller may do. Every request needs both, in that order, and they're often done by different components:

 [ mobile app ]
      │ 1. GET /v1/policies/PL-100246
      │    Authorization: Bearer eyJ... (token for C-20417)
      ▼
 ( Kong gateway )    AUTHENTICATION: is this token real?
      │              signature, issuer, audience, expiry
      │              missing or bad token ──▶ 401
      │              coarse AUTHZ (acl plugin): may this
      │              consumer call this route? no ──▶ 403
      │ 2. request + X-Consumer-ID: C-20417
      ▼
 ( policy-service )  AUTHORIZATION: may C-20417 read
      │              PL-100246? owner check (L05)
      │              not theirs ──▶ 404 (or 403)
      ▼
 ═══ MongoDB: policies ═══

Kong can tell you who is calling and whether they may use a route. Only policy-service knows which policies belong to C-20417, so the object-level check must live there (you met this as threat #3 in L05; it's the #1 API risk, L40). A quirk worth knowing: HTTP's 401 Unauthorized really means "unauthenticated", and 403 Forbidden means "authenticated, but not allowed".

2. Factors: know, have, are

An authentication factor is a category of evidence. There are three:

Factor Examples Typical weakness
Something you know Password, PIN, security question Phished, guessed, reused (L18)
Something you have Phone app, security key, smart card Codes it shows can be relayed
Something you are Fingerprint, face Usually only unlocks a local key

Multi-factor authentication (MFA) means proving identity with factors from at least two different categories. A password plus a security question is two knowledge checks, so it's not MFA: one phishing page collects both.

The "have" factor is really about a secret stored on a device: the seed inside your authenticator app, or the private key inside a security key. And a fingerprint on a laptop is normally checked locally to unlock that key; your fingerprint is never sent to the website.

3. Not all MFA is equal

CISA's 2022 fact sheet Implementing Phishing-Resistant MFA ranks MFA from strongest to weakest:

 STRONGEST  phishing-resistant: FIDO2/WebAuthn, PKI smart cards
     │      app/hardware OTP codes, push WITH number matching
     │      push WITHOUT number matching (approve/deny)
 WEAKEST    SMS or voice codes (+ SIM swap, SS7 interception)

CISA's point is that any MFA beats none, but phishing-resistant MFA is the gold standard. The weak rows share one flaw: the user reads something (or taps something) and nothing ties it to the website they're on. So a human can be tricked into handing it to the wrong site.

That's what an adversary-in-the-middle (AiTM) phishing kit exploits. It's a reverse proxy on a look-alike domain that shows the user the real sign-in pages, live:

 1. email "Re-verify your account" ──▶ https://sso-example.com
                                        (look-alike domain)
 2. user ──password + code 482913──▶ [ AiTM proxy ]
 3. proxy ──same password + code, seconds later──▶ real IdP
 4. real IdP ──"MFA ok" + Set-Cookie: session──▶ proxy
 5. proxy keeps the cookie; user lands on the real site
 6. attacker ──Cookie: session=...──▶ email, AWS portal
    (no password, no MFA prompt: the session already has MFA)

The code was valid; it was just used by the wrong person. Tools such as the open-source Evilginx framework do this, and phishing-as-a-service kits sell it. Deep dive 1 walks through it.

4. Why FIDO2 and passkeys resist phishing

FIDO2 is a pair of standards: WebAuthn, the browser API (a W3C standard), and CTAP, how the browser talks to a security key. At registration, the authenticator (a security key, or the secure chip in your laptop or phone) creates a new key pair for that one site. The site stores only the public key. The private key never leaves the authenticator.

At sign-in, the site sends a random challenge. The browser, not the user, adds the real origin of the page, and the authenticator signs the lot:

 REAL: https://sso.example.com
 browser ──"key for sso.example.com? sign challenge c1"──▶ key
 key ──finds that site's key pair, signs (c1 + origin)───▶ ok
 IdP ──checks origin + signature with stored public key──▶ ✔

 PHISH: https://sso-example.com
 browser ──"key for sso-example.com?"────────────────────▶ key
 key ──"nothing registered for that site"────────────────▶ ✘
 (and the browser refuses to ask for sso.example.com's key
  from a page whose domain isn't sso.example.com)

This is origin binding: the proof is bound to the domain, and there's nothing for the user to read, type or relay. A user can be fooled by a look-alike domain; the browser can't.

A passkey is a FIDO2 credential that can replace the password, not just add to it. They come in two kinds. Synced passkeys are backed up to an Apple, Google or password-manager account, so a lost phone isn't a lockout, but the sync account becomes part of your security. Device-bound passkeys never leave one device, such as a hardware security key. NIST SP 800-63B-4 allows synced passkeys at assurance level AAL2 but not at AAL3, which needs a non-exportable key.

5. MFA fatigue (push bombing)

An attacker who has your password (from stuffing, L18) can trigger sign-ins all night. Each one pings your phone: "Approve sign-in?" At 2 a.m., after the twentieth, some people tap Approve to make it stop. That's MFA fatigue, or push bombing. Uber's 2022 breach started this way (L28).

Number matching is the fix most vendors adopted: the sign-in page shows a number, and you must type it into the app. A prompt you didn't start has no number in front of you, so you can't approve it blindly. Microsoft has enforced it for all Authenticator push notifications since May 2023. Okta Verify's "number challenge" is a setting: never, only for high-risk sign-ins, or for all pushes.

Number matching stops blind approval, but not AiTM: the fake page simply shows the user the real number. Add limits on prompts, a "this wasn't me" button that alerts security, and phishing-resistant MFA for anyone with admin rights.

🧪 Worked example: who proves what, across a typical stack

Part A: authenticated isn't authorized. This request passes Kong (valid token for C-20417), but asks for someone else's policy:

# BEFORE: authenticated, never authorized
@app.get("/v1/policies/<pid>")
def get_policy(pid):
    doc = db.policies.find_one({"_id": pid})
    return doc or abort(404)

# AFTER: the service authorizes the object itself
@app.get("/v1/policies/<pid>")
def get_policy(pid):
    caller = verified_caller()   # from the token, not a
                                 # header anyone can set
    doc = db.policies.find_one(
        {"_id": pid, "ownerId": caller.customer_id})
    return doc or abort(404)     # same reply: not yours
                                 # or doesn't exist

Adding MFA, or more Kong plugins, would not fix the first version. MFA strengthens authentication; this is an authorization bug.

Part B: an MFA review of the identities in the example stack (made-up names):

Identity Who proves it What to check
An engineer in the AWS access portal IAM Identity Center, if the organization uses it If Identity Center's own directory is the identity source, set the MFA types to Security keys and built-in authenticators for admin permission sets. If it's connected to an external IdP, Identity Center's MFA settings don't apply: the IdP enforces MFA, so check there.
AWS root user (break-glass) AWS sign-in AWS now requires root MFA in every account type. Use two FIDO2 keys kept in separate safes, or remove member-account root credentials with centralized root access management.
GitHub user GitHub (often via org SSO) Passkey or security key, and remove SMS as a fallback
Airflow DAG, pod policy-service AWS STS No one can tap a key for a robot. Use workload identity instead: IRSA or EKS Pod Identity, GitHub OIDC (L19). Short-lived, bound to the workload, nothing to phish.

The last row matters: "add MFA to the service account" is the wrong fix. A TOTP seed stored next to the job's password is one secret in two places, not two factors.

🛠 Try it (optional, ~5 min)

See how an authenticator-app code is made, with a made-up secret, entirely on your machine. Save this as totp.py in a scratch folder and run python3 totp.py:

import base64, hashlib, hmac, struct, time

SECRET_B32 = "JBSWY3DPEHPK3PXP"   # made-up demo secret

def hotp(key, counter, digits=6):
    msg = struct.pack(">Q", counter)            # 8-byte counter
    mac = hmac.new(key, msg, hashlib.sha1).digest()
    off = mac[-1] & 0x0F                        # dynamic truncation
    code = struct.unpack(">I", mac[off:off+4])[0] & 0x7FFFFFFF
    return str(code % 10**digits).zfill(digits)

def totp(key, t=None, step=30, digits=6):
    t = time.time() if t is None else t
    return hotp(key, int(t // step), digits)

rfc_key = b"12345678901234567890"   # RFC 6238 test key
print("RFC 6238 test, T=59:", totp(rfc_key, 59, digits=8),
      "(expected 94287082)")
key = base64.b32decode(SECRET_B32)
now = time.time()
print("counter (time // 30):", int(now // 30))
print("code now:            ", totp(key, now))
print("code in 30 s:        ", totp(key, now + 30))
print("seconds left:        ", 30 - int(now) % 30)

Output on a Mac with Python 3 (your codes and counter will differ):

RFC 6238 test, T=59: 94287082 (expected 94287082)
counter (time // 30): 59799480
code now:             713235
code in 30 s:         949912
seconds left:         2

What to notice:

  • The first line matches the RFC's published test value, so this is the real algorithm.
  • The code depends only on the shared secret and the clock. Nothing in it says which website it's for, which is exactly why a phishing proxy can relay it.
  • If you add JBSWY3DPEHPK3PXP to an authenticator app as a manual entry, it shows the same codes. Delete it afterwards; it's a well-known demo value, not a real account.

🧠 Key takeaways

  • Authentication proves who; authorization decides what. A gateway such as Kong can authenticate, but only your service can authorize access to a specific object.
  • MFA needs two different factor types. Two passwords aren't MFA.
  • Anything a user reads and types or approves (SMS, TOTP, push) can be relayed by an AiTM proxy.
  • FIDO2/WebAuthn and passkeys bind the proof to the site's origin, so a look-alike domain gets nothing.
  • Number matching stops blind push approvals, not AiTM. Workloads use workload identity, not MFA.

📖 Words to know

Term Meaning
Authentication (authn) Proving who a user or service is
Authorization (authz) Deciding what an authenticated caller may do
Authentication factor A category of evidence: something you know, have, or are
MFA Authenticating with factors from at least two different categories
TOTP A 6-digit code computed from a shared secret and the current 30-second window
AiTM phishing A proxy on a look-alike site that relays your login live and steals the session
FIDO2 / WebAuthn Standards for signing in with a per-site key pair held by an authenticator
Origin binding The browser includes the real site's origin in what the authenticator signs
Passkey A FIDO2 credential that replaces a password; synced or device-bound
MFA fatigue Flooding a user with push prompts until they approve one
Number matching Typing a number from the sign-in screen into the app to approve a push
Workload identity Short-lived credentials for a pod or job, issued based on where it runs

⏭ Next time

L22: what happens after the login. Session IDs and cookies, how attackers hijack a session that already passed MFA, and the cookie flags (HttpOnly, Secure, SameSite) that make it harder.

Deep dive 1

How an adversary-in-the-middle phishing kit beats MFA (and what doesn't get beaten)

The trick: don't fake the site, forward it

Old phishing pages were copies of a login form. They collected a password and maybe a code, which the attacker then had to use quickly, by hand. An AiTM kit is smarter: it's a reverse proxy. Every page the user sees is the real IdP's page, fetched live and passed through, with links rewritten to stay on the attacker's domain. The user sees their company logo, their real MFA prompt, even their real "Welcome back".

Step by step

 user browser        AiTM proxy              real IdP
 (sso-example.com)   (attacker)              (sso.example.com)
   │ GET /login         │                        │
   │───────────────────▶│ GET /login ───────────▶│
   │◀── real page ──────│◀── real page ──────────│
   │ POST user+password │                        │
   │───────────────────▶│ POST user+password ───▶│  logged:
   │                    │                        │  password
   │◀── "enter code" ───│◀── "enter code" ───────│
   │ POST code 482913   │                        │
   │───────────────────▶│ POST code 482913 ─────▶│  logged:
   │                    │◀── Set-Cookie: sid=.. ─│  code
   │◀── redirect to the │  SAVES sid             │
   │    real site       │                        │
   │                    │                        │
 attacker's own browser: Cookie: sid=..  ─────────▶ signed in

The prize isn't really the password; it's sid, the session cookie the IdP issues after MFA. With it, the attacker's browser is treated as the user's browser for as long as the session lives. Microsoft's 2022 write-up described exactly this, and stressed that it isn't a vulnerability in MFA: MFA checks the sign-in once, and the stolen session works however that sign-in was done.

Push with number matching doesn't help here: the proxy shows the user the real number, and the user types it in, believing they're signing in to the real site (they are, through the proxy).

What the defender sees

In IdP sign-in logs, AiTM leaves a pattern you can alert on (fake IPs from documentation ranges):

09:02:11 user=ana  event=login   mfa=totp   ip=203.0.113.50
         ua="Chrome/130 Windows" asn=hosting-provider
09:03:40 user=ana  event=session_use        ip=198.51.100.7
         ua="Chrome/130 Windows" asn=other-hosting
09:04:02 user=ana  event=mail_rule_created  "move *invoice*"

Clues: the sign-in came from a hosting provider rather than a home or office network; the session jumped to a different IP a minute later with no new sign-in; and the first action was a mail rule to hide replies, a classic step before payment fraud.

What doesn't get beaten

Control Why the proxy loses
FIDO2 / passkeys The browser on sso-example.com won't ask for sso.example.com's key; if a signature is somehow relayed, its signed origin is wrong and the IdP rejects it
Managed-device access policies If the IdP only issues sessions to managed, compliant devices, the proxy's requests usually lack the device's proof
PKI smart cards with TLS client certs The card proves itself inside the TLS handshake; the proxy ends that TLS connection and can't present the user's certificate onward without its private key

And what still needs work even with phishing-resistant MFA:

  • Weaker fallbacks. If the user also has SMS or TOTP registered, the kit can claim "security key not available" and steer them to the phishable method. For admins, remove the fallbacks.
  • Stolen cookies after a good login. Infostealer malware on a laptop copies session cookies from the browser profile. Origin binding doesn't help; short session lifetimes, re-authentication for sensitive actions, and binding sessions to the device do. Google's Device Bound Session Credentials proposal is one such effort (check current browser support). That's L22's territory.
  • Recovery flows. If the help desk resets a passkey after a convincing phone call, the attacker never needed to phish the key. Make recovery as strong as sign-in.

🛠 Try it (~5 min, look only)

Open the sign-in or security settings of an account you own (GitHub's Settings → Password and authentication is a good one). List every method that can complete a sign-in, including the recovery ones. Which of them would a reverse proxy relay? If a passkey or key is there, are the weaker ones still enabled as fallbacks?

🧠 Extra takeaways

  • AiTM kits proxy the real site live and steal the post-MFA session cookie.
  • Sign-in logs show it: hosting-provider IPs, a session moving IPs without a new login, mail rules.
  • FIDO2 beats the proxy; weaker fallbacks, cookie theft and weak recovery can still undo it.
Term Meaning
Reverse proxy (phishing) A server that forwards a real site's pages to the victim and the victim's input to the real site
Session cookie The cookie a site issues after login that stands in for your identity on later requests
Downgrade attack (MFA) Steering a user from a strong method to a weaker registered one
Infostealer Malware that copies saved passwords and session cookies from a device
Deep dive 2

TOTP and WebAuthn under the hood

TOTP: an HMAC of the clock

The Try-it computed HOTP (RFC 4226) on a counter and TOTP (RFC 6238), which simply makes the counter the current time divided by 30 seconds:

 shared secret K (enrolled once, by QR code)
 counter T = floor(unix_time / 30)        e.g. 59799480
         │
         ▼
 HMAC-SHA1(K, T as 8 bytes)  ──▶ 20-byte MAC
         │
         ▼
 dynamic truncation: last 4 bits pick an offset; take 4 bytes
 there, drop the top bit ──▶ 31-bit number
         │
         ▼
 number mod 10^6 ──▶ "713235"

It's the HMAC from L17, with the clock as the message. Some details that matter when you build or review a verifier:

  • The server must keep the secret, not a hash of it. Unlike a password (L18), it needs K to compute the expected code. Encrypt the seeds with a KMS key (L19) and keep them out of logs.
  • Clock drift. Verifiers usually also accept the previous or next window. Each extra window widens the time a stolen code stays usable.
  • One use only. NIST requires that an OTP be accepted only once. Record the last used counter per user and reject repeats, or a shoulder-surfed code works twice.
  • Rate-limit guesses. A 6-digit code has a million values. Without limits, an attacker who has the password can try codes until one fits a window.

WebAuthn: a signature over the origin

WebAuthn has two ceremonies. At registration, the authenticator makes a key pair for the site's relying party ID (rpId, usually its domain) and returns the public key and a credential ID. At authentication, the server sends a challenge and the browser builds clientDataJSON:

{
  "type": "webauthn.get",
  "challenge": "q3Vb8mN1xLkP0w2Rt9sYcA",
  "origin": "https://sso.example.com",
  "crossOrigin": false
}

The authenticator returns authenticatorData and a signature:

 authenticatorData
 ┌────────────────────────┬────────┬───────────────┐
 │ rpIdHash (32 bytes)    │ flags  │ signCount     │
 │ SHA-256("sso.          │ UP UV  │ (4 bytes)     │
 │  example.com")         │ BE BS  │               │
 └────────────────────────┴────────┴───────────────┘
 signature = Sign(private key,
                  authenticatorData ‖ SHA-256(clientDataJSON))

The flags say whether the user was present (UP, a touch) and verified (UV, a PIN or fingerprint), and whether the credential is backup-eligible or backed up (BE, BS: a synced passkey). The server checks, in order:

  1. type is webauthn.get and challenge is the one it just issued (no replay).
  2. origin is exactly its own origin, and rpIdHash is the hash of its own rpId.
  3. UP is set, and UV is set if policy requires it.
  4. The signature verifies with the stored public key for that credential ID.

Step 2 is where phishing dies. A signature produced on sso-example.com carries that origin, and the server compares it with its own. Signing the origin is the same idea as signing the message in L17: change one byte and the check fails.

What a database leak costs you

Stored on the server If the database leaks
Password hash (L18) Offline guessing, weak passwords fall
TOTP seeds Attacker can compute every user's codes
WebAuthn public keys Nothing to sign in with: public keys are public

That's the quiet win of WebAuthn: the server holds no secret that lets anyone sign in.

🛠 Try it (~5 min, describe, don't run)

If you'd like to watch a ceremony, the public demo site webauthn.io lets you register a throwaway username with your laptop's built-in authenticator and sign back in. In your browser's dev tools Network tab, find the request that posts the response and look for clientDataJSON (base64url). Decode it and you'll see the origin field from the JSON above. Use a made-up username, and remove the passkey from your device's passkey list afterwards.

🧠 Extra takeaways

  • TOTP is HMAC-SHA1 over a 30-second counter; the server must store the seed itself, so protect it.
  • Accept each OTP once, allow little drift, and rate-limit attempts.
  • WebAuthn signs the challenge plus the origin; the server rejects any other origin.
  • A leaked WebAuthn database holds only public keys.
Term Meaning
HOTP One-time password from an HMAC of a counter (RFC 4226)
Dynamic truncation HOTP's way of turning a 20-byte MAC into a short decimal code
Relying party ID (rpId) The domain a WebAuthn credential is scoped to
clientDataJSON Browser-built data with the challenge and origin, covered by the signature
UP / UV flags Whether the user was present (touch) and verified (PIN or biometric)

The quiz for this lesson and the Tests section need JavaScript.