On this page:
Learning Objectives
Motivation:   Terp  Tasks Needs to Know Who’s Asking
9.1 Authentication vs. Authorization
9.2 JWT Structure
9.2.1 Header
9.2.2 Payload
9.2.3 Signature
9.3 Sessions vs. Token-Based Authentication
9.3.1 Session-based
9.3.2 Token-based
9.3.3 Comparison
9.4 Storing Tokens and Sending Them
9.4.1 Option 1:   local  Storage
9.4.2 Option 2:   Http  Only cookie
9.5 The Authorization Header and Fast  API Authentication
9.5.1 The authentication dependency
9.5.2 Issuing the token:   the login endpoint
9.6 RBAC:   Role-Based Access Control
9.6.1 Dependency-based authorization
9.7 CORS
9.7.1 What CORS actually does
9.7.2 The Authorization header and CORS
9.7.3 Preflight requests
9.8 Common CORS Mistakes and Debugging
9.8.1 The debugging workflow
9.9 Putting It All Together:   The Full Security Flow
9.9.1 Five takeaways
Appendix:   Refresh Tokens and Revocation
9.10 Git  Hub-Ready Mini-Project:   terptasks-auth
Practice Exercises
Basic
Intermediate
Advanced
Summary
Key takeaways
Terminology
Common mistakes
Connections
9.1

9 Authentication, Authorization & CORS🔗

    Learning Objectives

    Motivation: TerpTasks Needs to Know Who’s Asking

    9.1 Authentication vs. Authorization

    9.2 JWT Structure

      9.2.1 Header

      9.2.2 Payload

      9.2.3 Signature

    9.3 Sessions vs. Token-Based Authentication

      9.3.1 Session-based

      9.3.2 Token-based

      9.3.3 Comparison

    9.4 Storing Tokens and Sending Them

      9.4.1 Option 1: localStorage

      9.4.2 Option 2: HttpOnly cookie

    9.5 The Authorization Header and FastAPI Authentication

      9.5.1 The authentication dependency

      9.5.2 Issuing the token: the login endpoint

    9.6 RBAC: Role-Based Access Control

      9.6.1 Dependency-based authorization

    9.7 CORS

      9.7.1 What CORS actually does

      9.7.2 The Authorization header and CORS

      9.7.3 Preflight requests

    9.8 Common CORS Mistakes and Debugging

      9.8.1 The debugging workflow

    9.9 Putting It All Together: The Full Security Flow

      9.9.1 Five takeaways

    Appendix: Refresh Tokens and Revocation

    9.10 GitHub-Ready Mini-Project: terptasks-auth

    Practice Exercises

      Basic

      Intermediate

      Advanced

    Summary

      Key takeaways

      Terminology

      Common mistakes

      Connections

Learning Objectives🔗

By the end of this lecture you should be able to:

  1. Distinguish authentication (who are you?) from authorization (what are you allowed to do?) from CORS (is this browser origin allowed to see the response?), and explain why conflating any two of them causes real security bugs.

  2. Describe the three parts of a JWT (header, payload, signature), state precisely what the signature does and does not guarantee, and explain why a JWT payload must never hold secrets.

  3. Compare session-based and token-based authentication along concrete axes (server state, revocation, scaling) without treating either as unconditionally “better.”

  4. Weigh the tradeoffs of storing a token in localStorage versus an HttpOnly cookie, and articulate what each does and does not protect against.

  5. Implement a reusable FastAPI authentication dependency that extracts, verifies, and decodes a Bearer token, and layer role-based authorization on top of it with a dependency factory.

  6. Correctly choose between a 401 and a 403 response for a given failure, and explain the semantic difference to another developer.

  7. Explain the same-origin policy, configure FastAPI’s CORSMiddleware correctly, and predict when a browser will send a preflight OPTIONS request.

  8. Debug a CORS failure systematically using browser DevTools instead of guessing, and recognize (and avoid) the four most common CORS misconfigurations.

  9. Trace one HTTP request end to end through CORS, authentication, and authorization, and say which layer would reject it and why.

Motivation: TerpTasks Needs to Know Who’s Asking🔗

TerpTasks, by the end of the REST/FastAPI and React lectures, is a working full-stack app: a FastAPI backend with clean CRUD endpoints, a React frontend that renders and edits tasks, CORS enabled so the two can even talk to each other. There is exactly one problem, and it’s a big one: every endpoint trusts every request. Anyone who can reach the server — any browser, any curl command, any other student on the network — can list, create, edit, or delete anyone’s tasks. There is no notion of “whose tasks are these” at all.

TerpTasks is about to become a real course-management tool, which means it needs users with different jobs:

  • a student should see and manage only their own tasks,

  • an instructor should be able to create assignments for a course, and

  • an admin should be able to manage the whole system.

Getting from “every request is trusted” to “the server knows who you are and what your role allows” takes three distinct pieces, introduced in the order the plan above walks through them:

User

  |

Login

  |

FastAPI authenticates credentials

  |

JWT issued

  |

React stores/sends token

  |

Authorization: Bearer <token>

  |

FastAPI verifies JWT

  |

Check user role

  |

Allow / reject request

Every section below fills in one link of that chain. By the end, TerpTasks will run a login endpoint, issue signed tokens, verify them on every protected route, and enforce role-based rules — and you’ll be able to explain exactly which of the three security concepts ( authentication, authorization, CORS) is responsible when a request fails, and why.

9.1 Authentication vs. Authorization🔗

Formal definition. Authentication answers who are you? it establishes an identity. Authorization answers what are you allowed to do? it decides, given an established identity, whether a specific action is permitted.

Plain English. Authentication is showing your CampusID at the building door; authorization is whether that card actually opens this door. The card reader doesn’t re-check your identity for every door in the building — it already knows who you are from the swipe. It checks a separate thing: does this person’s card unlock this door?

username + password

        |

   Authentication

        |

     "Alice"

Alice

  |

role = instructor

  |

Can create assignments?

Yes

Intuition — why keep them separate? Because they fail for different reasons and are fixed in different places. “Wrong password” is an authentication failure — the server never learned who you are. “Alice authenticated fine, but students can’t create assignments” is an authorization failure — the server knows exactly who Alice is and is still saying no. Merging the two concepts into one “are you allowed in” check is exactly how bugs like “any logged-in user can hit the admin endpoint” get shipped: someone verified the token and stopped there, forgetting that being someone is not the same as being allowed.

A third concept belongs in the same table, and this lecture spends its second half on it:

Concept

Question

TerpTasks example

Authentication

Who are you?

Alice logs in

Authorization

What can you do?

Alice (instructor) can create assignments

CORS

Can this browser frontend call this backend?

React (:5173) -> FastAPI (:8000)

Misconception. “If CORS is configured, the API is protected.” Hold onto this now, because it’s worth repeating after every section: CORS is not an authentication mechanism, and it is not an authorization mechanism. It is a browser-only rule about which origins may read a response. It says nothing about who the user is or what they’re allowed to do, and it does nothing at all against a client that isn’t a browser. Section CORS makes this precise; for now, just don’t let the three rows of that table blur into one.

9.2 JWT Structure🔗

TerpTasks needs a way to represent “the server already checked this person’s password, and here’s who they are” so that later requests don’t have to send the password again. A JWT (JSON Web Token) is the standard shape for that claim.

Formal definition. A JWT is a compact, URL-safe string encoding a set of claims (statements about a subject), cryptographically signed so that any tampering is detectable. It has three dot-separated parts:

xxxxx.yyyyy.zzzzz

  |      |      |

header payload signature

9.2.1 Header🔗

The header conceptually looks like this:

{
  "alg": "HS256",
  "typ": "JWT"
}

alg names the signing algorithm; typ just says “this is a JWT.” Nothing here is secret — it describes how the token is signed, not what it contains.

9.2.2 Payload🔗

The payload is where TerpTasks puts the actual claims about the authenticated user:

{
  "sub": "alice",
  "role": "instructor",
  "exp": 1780000000
}

sub (subject) is the user identifier; exp is an expiration timestamp; iat (issued-at) is common too. role is not part of the JWT standard — it’s an application-specific claim TerpTasks adds because RBAC (RBAC: Role-Based Access Control) needs to know the user’s role without a second database lookup on every request.

9.2.3 Signature🔗

signature =

    Sign(

        base64url(header) + "." + base64url(payload),

        secret

    )

The signature is computed over the header and payload using a secret (or private key) only the server knows, and it’s what makes the token trustworthy: change one character of the payload, and re-verifying the signature fails.

The critical point, worth its own paragraph: JWT payloads are encoded, not encrypted. Base64url encoding is not a cipher — it’s a reversible text encoding, exactly like Base64. Anyone holding a JWT, including the browser and the user themself, can decode the header and payload and read them as plain JSON. Therefore: never put a password, an API key, or any secret value in a JWT payload. Put an identifier and non-sensitive claims there — sub, role, exp and nothing an adversary reading the token shouldn’t already know.

Mini demonstration. Paste a real JWT into jwt.io (or decode it by hand: base64url-decode the first two dot-separated sections) and ask:

  • Which part can the browser decode? The header and the payload — both are plain base64url, no key required.

  • Can the browser change "role": "student" to "role": "admin" and produce a token the server still accepts? No assuming the backend actually re-verifies the signature on every request (Section The Authorization Header and FastAPI Authentication builds exactly that check). Editing the payload changes its bytes, so the old signature no longer matches; without the server’s secret, there’s no way to compute a new signature that does.

The entire security property of a JWT rests on that one fact: the payload is public, but only the server can produce a signature the server itself will accept.

9.3 Sessions vs. Token-Based Authentication🔗

JWTs are one way to represent “this request comes from an authenticated user.” The traditional alternative is a session, and TerpTasks could have been built either way.

9.3.1 Session-based🔗

Browser

   |

   | session cookie

   v

Server

   |

   v

Session store

The browser holds a small, opaque session id in a cookie; the server looks that id up in a session store (in memory, in Redis, in a database table) to find out who’s asking and what they’re allowed to do. The server maintains the state.

9.3.2 Token-based🔗

Browser

   |

   | JWT

   v

Server

   |

   v

Verify token

The browser holds a JWT; the server verifies its signature and reads the claims directly out of the token. No lookup is required — the token is the state, signed so the server can trust it without storing anything.

9.3.3 Comparison🔗

Sessions

JWT / token

Server state

usually required (a session store)

can be stateless

Revocation

relatively straightforward (delete the row)

more complicated (see the appendix)

Scaling

needs shared session storage or sticky sessions

easier to distribute across servers

Token size

small cookie/session id

can be larger (claims travel with it)

Storage strategy

cookie-based, mostly settled

matters --- see the next section

Common use

traditional server-rendered web apps

APIs, mobile apps, single-page apps

Don’t present JWT as automatically “better.” It trades a server-side lookup for a cryptographic check, which is a genuine win for horizontally scaled APIs with no shared session store — but it gives up the trivial revocation a session store provides for free. The honest framing:

JWT solves token representation and verification; it does not automatically solve every authentication problem. It’s the right tool when statelessness and scaling matter more than instant revocation; a session is often still the right tool for a traditional server-rendered site. TerpTasks, as a React SPA talking to a FastAPI API, is exactly the profile that motivates the token approach — which is why the rest of this lecture builds on JWTs.

9.4 Storing Tokens and Sending Them🔗

Once FastAPI hands React a JWT, React has to keep it somewhere and attach it to every future request. This is the section that should generate the most discussion, because both common answers have real security tradeoffs and neither is simply “the secure one.”

9.4.1 Option 1: localStorage🔗

localStorage.setItem("token", jwt);

and then on every request:

Authorization: Bearer eyJ...

Advantages. Trivial to read and write from JavaScript; convenient for an SPA that attaches the token itself; the frontend code has full, explicit control over when the token is sent.

Disadvantage. Anything that can run JavaScript on your page can also run localStorage.getItem("token"). If an attacker achieves XSS (cross-site scripting — getting their own script to execute in your page, typically by injecting it through unsanitized user input rendered back into the DOM), that script can read the token directly and exfiltrate it.

9.4.2 Option 2: HttpOnly cookie🔗

The server sets the cookie instead of handing the token to JavaScript at all:

Set-Cookie: session=...; HttpOnly; Secure

HttpOnly tells the browser: never expose this cookie’s value to JavaScript, full stop — document.cookie simply won’t show it.

Advantage. Because JavaScript can’t read it, even a successful XSS injection can’t directly steal the token by calling localStorage.getItem or document.cookie.

Tradeoff. Cookies are attached to requests automatically by the browser, for any request to the cookie’s domain — which is exactly what makes CSRF (cross-site request forgery: a malicious page tricks your browser into firing a request to a site you’re already logged into, and the browser helpfully attaches your cookie) a real concern that XSS-only-localStorage doesn’t have in the same way. Using cookies safely means also getting SameSite, cross-origin credential configuration (Section CORS revisits this), and CSRF defenses right — it doesn’t remove work, it relocates it.

The important teaching point. Resist the shortcut:

“Cookies are secure and localStorage is insecure.”

is a false sentence people repeat anyway. The accurate one: both approaches have different security properties and need to be configured correctly. localStorage is vulnerable to XSS reading the token directly; HttpOnly cookies are not, but open up CSRF if you don’t add the matching protections. Neither choice is a substitute for not having XSS-vulnerable code in the first place — sanitize input, escape output, and treat this as defense in depth, not a single silver-bullet switch.

9.5 The Authorization Header and FastAPI Authentication🔗

Whichever storage strategy React uses, the token has to travel to FastAPI on every protected request. The standard vehicle is the Authorization header with the Bearer scheme:

GET /api/courses

 

Authorization: Bearer eyJhbGciOi...

Authorization

      |

    Bearer

      |

     JWT

Bearer means exactly what it sounds like: whoever holds this token is treated as authenticated there’s no additional proof of identity beyond possessing the string, which is precisely why Section Storing Tokens and Sending Them’s storage question matters so much. Anyone who can read the token can replay it.

9.5.1 The authentication dependency🔗

FastAPI’s answer to “check this on every protected route” is dependency injection: write the check once, as a function, and let every route that needs it declare Depends(...) on it. OAuth2PasswordBearer is a small helper that knows how to pull the token out of the Authorization: Bearer ... header (and tells FastAPI’s auto-generated docs how to prompt for it):

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
import jwt

app = FastAPI()

SECRET_KEY = "change-me-in-production"   # load from an env var, never hard-code
ALGORITHM = "HS256"

# tokenUrl tells the interactive /docs page where to POST for a token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")


async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
    credentials_error = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.ExpiredSignatureError:
        raise credentials_error
    except jwt.InvalidTokenError:
        raise credentials_error

    username = payload.get("sub")
    role = payload.get("role")
    if username is None or role is None:
        raise credentials_error

    return {"username": username, "role": role}

Trace what this dependency does, in order, matching the steps from the motivation diagram:

  1. Extracts the token oauth2_scheme pulls the raw string out of the Authorization header (a missing header raises 401 before this function even runs).

  2. Verifies the signature jwt.decode recomputes the signature with SECRET_KEY and rejects the token if it doesn’t match.

  3. Checks expiration jwt.decode also rejects an expired exp claim automatically, raising ExpiredSignatureError.

  4. Extracts the user identity reads sub and role out of the now-trusted payload.

  5. Returns the authenticated user for the route to use.

And the endpoint that uses it:

@app.get("/api/courses")
async def get_courses(user: dict = Depends(get_current_user)):
    return {"courses": [...], "requested_by": user["username"]}

The key FastAPI concept: authentication becomes a reusable dependency. Every protected route adds one parameter — Depends(get_current_user) and gets the full extract / verify / expire / identify pipeline for free, written and tested once. This is the same dependency-injection idea the REST/FastAPI lecture used for a shared data store, applied to a cross-cutting security concern instead.

9.5.2 Issuing the token: the login endpoint🔗

get_current_user verifies tokens; something has to issue them first, after checking a password:

from datetime import datetime, timedelta, timezone
from fastapi.security import OAuth2PasswordRequestForm

def create_access_token(sub: str, role: str, expires_minutes: int = 30) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": sub,
        "role": role,
        "iat": now,
        "exp": now + timedelta(minutes=expires_minutes),
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


@app.post("/auth/login")
async def login(form: OAuth2PasswordRequestForm = Depends()):
    user = authenticate_user(form.username, form.password)   # checks a hashed password
    if user is None:
        raise HTTPException(status_code=401, detail="Incorrect username or password")
    token = create_access_token(sub=user.username, role=user.role)
    return {"access_token": token, "token_type": "bearer"}

authenticate_user is ordinary application code — look the user up, compare the submitted password against a stored hash (never a stored plaintext password), return the user or None. Everything downstream of a successful login is the JWT machinery above.

9.6 RBAC: Role-Based Access Control🔗

Authentication (the dependency above) answers who. TerpTasks still needs to answer what are they allowed to do an instructor should be able to create an assignment; a student should not.

Authentication

      |

Who is Alice?

      |

    Alice

      |

What is Alice's role?

      |

 instructor

      |

Is instructor allowed?

      |

   Yes / No

Formal definition. RBAC (role-based access control) grants permissions to roles (student, instructor, admin) rather than to individual users, and assigns each user one or more roles. Checking authorization becomes “does this user’s role have this permission,” not “does this specific person.”

9.6.1 Dependency-based authorization🔗

The same dependency-injection idea from Section The Authorization Header and FastAPI Authentication composes naturally: write a dependency factory a function that returns a dependency, parameterized by the role it requires:

def require_role(required_role: str):
    async def checker(user: dict = Depends(get_current_user)) -> dict:
        if user["role"] != required_role:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Forbidden",
            )
        return user

    return checker

require_role("instructor") builds on top of get_current_user: it authenticates first, then checks the role, so a route that uses it gets both checks, in the correct order, from one Depends:

@app.post("/api/assignments")
async def create_assignment(user: dict = Depends(require_role("instructor"))):
    ...

401 vs. 403

This distinction is worth its own callout, because the two are routinely confused and the confusion leaks into API responses that mislead every client that has to handle them.

401 Unauthorized “I don’t know who you are.”

No token

Invalid token

Expired token

Every one of these is an authentication failure: the server never established an identity, so it can’t even ask the authorization question yet.

403 Forbidden “I know exactly who you are, and the answer is no.”

Authenticated user

       |

   role = student

       |

POST /api/assignments

       |

   403 Forbidden

The student authenticated successfully — get_current_user returned a valid user — and require_role("instructor") still rejected the request, because authentication succeeding and authorization succeeding are two different checks.

Mnemonic: 401 = “who are you?” 403 = “I know who you are, but no.” Returning 403 for a missing token (there was no “who” to know) or 401 for a wrong role (the server does know who this is) is a common and avoidable API-design mistake — and exactly the kind of inconsistency Section HTTP Fundamentals of the REST/FastAPI lecture warned would become a permanent workaround for every client once it ships.

9.7 CORS🔗

Every piece so far assumed the request already reached FastAPI. Before it can, when the caller is a browser, one more gate has to open: CORS.

React

http://localhost:5173

 

        | HTTP request

 

FastAPI

http://localhost:8000

Why does the browser care at all? Without any restriction, JavaScript running on any page you happen to visit could silently make requests to any other site using your browser — your bank, your email, TerpTasks — and read the responses. The browser prevents this with the same-origin policy: JavaScript from one origin cannot freely read responses from a different origin, unless that origin explicitly says it’s fine.

Formal definition. An origin is the triple scheme + host + port. http://localhost:3000 and http://localhost:8000 are different origins same scheme, same host, different port. Port alone is enough to make two origins distinct.

9.7.1 What CORS actually does🔗

CORS (Cross-Origin Resource Sharing) is the mechanism by which a server tells browsers which other origins may read its responses. In FastAPI:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

This says, roughly: allow the frontend at http://localhost:3000 to make cross-origin requests to this FastAPI app, and let the browser expose the response to that page’s JavaScript. Without it, the request may still reach the server (the server processes it and sends a response) — the browser just refuses to hand that response to the page’s script.

CORS is primarily a browser enforcement mechanism. This is the single most important sentence in the section, because of what follows from it:

CORS does not stop someone from using curl, Postman, or another backend from calling your API. Those clients aren’t browsers; they don’t enforce the same-origin policy at all, and a bare HTTP request has no concept of “origin” to check in the first place.

Therefore, restated from Section Authentication vs. Authorization with the full mechanism now in view:

CORS != authentication

CORS != authorization

An allow_origins list that includes http://localhost:3000 says nothing about whether a request from that origin is logged in or permitted to do anything — it only controls whether a browser will let JavaScript at that origin see the response. The get_current_user and require_role dependencies from the previous two sections are the actual protection; CORS configuration is orthogonal to both and enforced only in browsers.

9.7.2 The Authorization header and CORS🔗

This is the seam where all three concepts of this lecture touch one request at once. React sends:

GET /api/users

Authorization: Bearer eyJhbGciOi...

A custom header like Authorization is exactly the kind of thing CORS has to be told to allow through:

allow_headers=["Authorization", "Content-Type"]

React

   |

   | Authorization: Bearer JWT

   v

FastAPI

   |

   +-- CORS check

   |

   +-- JWT verification

   |

   +-- Authorization/RBAC check

Three different gates, three different failure modes, three different fixes: a CORS failure is fixed in CORSMiddleware’s configuration, a JWT failure is fixed in get_current_user, and an RBAC failure is fixed in require_role. Knowing which gate rejected a request is the entire debugging skill of this lecture’s second half.

9.7.3 Preflight requests🔗

Some cross-origin requests aren’t sent directly — the browser first sends a preflight: an OPTIONS request asking permission before it asks the question for real.

OPTIONS /api/users

Origin: http://localhost:3000

Access-Control-Request-Method: GET

Access-Control-Request-Headers: Authorization

The server answers:

Access-Control-Allow-Origin: http://localhost:3000

Access-Control-Allow-Methods: GET, POST

Access-Control-Allow-Headers: Authorization, Content-Type

and only if the browser is satisfied with that answer does it send the actual request:

GET /api/users

Authorization: Bearer ...

Why bother? Because some cross-origin requests can have real side effects on the server, the browser wants the server to explicitly say “yes, this method, these headers, from this origin, are fine” before it commits to sending the real one. A custom header such as Authorization is one of the common triggers for this preflight behavior:

Cross-origin request

       |

       +-- "simple" request

       |       |

       |    actual request

       |

       +-- non-simple request

               |

           OPTIONS

          (preflight)

               |

          actual request

You don’t need the formal specification’s exact boundary between “simple” and “non-simple” for this course — just the shape: some requests get an invisible round trip first, and Authorization headers are a common reason one shows up in the Network tab.

9.8 Common CORS Mistakes and Debugging🔗

Four mistakes account for almost every CORS bug you’ll hit this semester.

1. Wrong origin. allow_origins=["http://localhost:3000"] does not match a frontend actually running at http://localhost:5173 a different port is a different origin, full stop, no partial credit.

2. Missing protocol. "localhost:3000" is not a valid origin string; it must be "http://localhost:3000", scheme included.

3. Credentials + wildcard. Don’t casually combine

allow_credentials=True
allow_origins=["*"]

Credentialed cross-origin requests (cookies, or an Authorization header sent with credentials mode) come with tighter browser restrictions specifically because a wildcard origin plus credentials would let any site ride along with a logged-in user’s session. List explicit origins once credentials are in play.

4. Forgetting headers. The frontend sends Authorization: Bearer ..., but allow_headers doesn’t permit it — the preflight succeeds for the method but rejects the header, and the real request never goes out.

9.8.1 The debugging workflow🔗

Don’t guess. Open:

Browser DevTools

      |

   Network

      |

   Request

      |

Request Headers

Response Headers

      |

Look for Access-Control-* headers

and check, in order: the request URL, the Origin header actually sent, whether an OPTIONS preflight happened at all, its response status, and whether Access-Control-Allow-Origin / -Allow-Methods / -Allow-Headers match what the real request needs.

OPTIONS /api/users

        |

       403

        |

CORS configuration problem

versus the working case:

OPTIONS /api/users

        |

       200

        |

GET /api/users

        |

       200

The most important lesson of this section: read the browser’s CORS error message and inspect the actual request/response in the Network tab the console error nearly always names the missing piece (the origin, the header, the method) directly.

9.9 Putting It All Together: The Full Security Flow🔗

Every piece from this lecture is one link in a single chain. Trace a request for /api/courses end to end:

   +-----------------+

   |      React      |

   +--------+--------+

            |

       Login request

            |

            v

   +-----------------+

   |     FastAPI     |

   +--------+--------+

            |

     Verify credentials

            |

            v

       Issue JWT

            |

            v

   +-----------------+

   |      React      |

   +--------+--------+

            |

Authorization: Bearer JWT

            |

            v

   +-----------------+

   |     FastAPI     |

   |                 |

   | CORS check      |

   |       |         |

   | Verify JWT      |

   |       v         |

   | Identify user   |

   |       v         |

   | Check role      |

   +--------+--------+

            |

     +------+------+

     |             |

   allowed       denied

     |             |

     v             v

    200           401/403

9.9.1 Five takeaways🔗
  1. Authentication establishes who the user is.

  2. Authorization determines what the authenticated user can do.

  3. JWTs contain signed claims; they are not encrypted by default — never put secrets in the payload.

  4. RBAC composes with FastAPI dependencies, and should distinguish 401 (no identity yet) from 403 (identity known, action denied).

  5. CORS is a browser security mechanism, not an authentication or authorization mechanism — it decides who may read a response, not who may make a request.

Appendix: Refresh Tokens and Revocation🔗

Not covered in the 75-minute lecture, but the direct answer to the “revocation is more complicated” row from Section Sessions vs. Token-Based Authentication’s comparison table — read this before you ship a real login system.

A JWT that’s valid for 30 minutes is a problem the moment you need to kill it sooner a user changes their password, an admin bans an account, a laptop is stolen. Unlike a session row, you can’t just delete it: the server never stored it, and any copy of a still-unexpired, still-correctly-signed token remains valid no matter what the database now says.

Two patterns fix this in practice:

Short-lived access tokens plus a refresh token. Issue a JWT that expires in minutes (an access token) alongside a second, longer-lived refresh token stored server-side (so it can be revoked, e.g. by deleting its database row). The client trades the refresh token for a new access token every few minutes; revoking access now means invalidating the refresh token, and the damage window from a stolen access token shrinks to its short expiry instead of the full session length.

@app.post("/auth/refresh")
async def refresh(refresh_token: str):
    stored = get_refresh_token(refresh_token)      # a DB lookup -- revocable
    if stored is None or stored.revoked:
        raise HTTPException(status_code=401, detail="Invalid refresh token")
    return {"access_token": create_access_token(stored.username, stored.role)}

A denylist for genuinely urgent revocation. Keep a small, fast store (e.g. Redis) of token identifiers (a jti claim) that must be rejected immediately even though they haven’t expired — used sparingly, for the “ban this user right now” case, since it reintroduces exactly the server-side lookup JWTs were chosen to avoid.

Neither pattern is free: both trade back some of the statelessness that made JWTs attractive in Section Sessions vs. Token-Based Authentication in exchange for the revocation control a plain JWT can’t offer on its own. That tradeoff, not a a flaw in JWTs themselves, is the honest answer to “how do I log someone out.”

9.10 GitHub-Ready Mini-Project: terptasks-auth🔗

See the project on GitHub

Practice Exercises🔗

Basic🔗
  • 1. Decode a JWT by hand. Take any JWT (e.g. from jwt.io’s example), split it on ., base64url-decode the first two parts, and show the resulting header and payload JSON. Confirm you did not need the secret to do this.

  • 2. 401 or 403? For each of the following, say which status code TerpTasks should return and why: (a) no Authorization header at all, (b) a well-formed but expired token, (c) a valid token belonging to a student hitting POST /api/assignments, (d) a token signed with the wrong secret.

  • 3. Spot the CORS mistake. Given allow_origins=["localhost:3000"] and a frontend running at http://localhost:3000, explain exactly why the browser still blocks the request, and fix the configuration.

Intermediate🔗
  • 4. Add an admin role. Extend require_role to accept a list of acceptable roles (e.g. require_role(["instructor", "admin"])) instead of exactly one, and add an admin-only DELETE /api/courses/{id} route.

  • 5. Trigger and read a real preflight. Run the mini-project with the React dev server, open DevTools’ Network tab, and capture an OPTIONS request. Identify the Access-Control-Request-Method and Access-Control-Request-Headers the browser sent, and match them against the server’s response headers.

  • 6. Break the signature on purpose. Take a valid token from the mini-project, flip one character in the payload segment (re-encode it), and send it to /api/courses. Confirm you get 401, and explain in one sentence why changing the payload alone is enough to invalidate it.

Advanced🔗
  • 7. Implement the refresh-token appendix. Add a server-side refresh token table (even an in-memory dict keyed by a random id is fine), a POST /auth/refresh route, and a POST /auth/logout route that revokes a specific refresh token. Write a test that logs in, refreshes, then confirms a revoked refresh token can no longer mint new access tokens.

  • 8. Move the token to an HttpOnly cookie. Change /auth/login to set the JWT via Set-Cookie: token=...; HttpOnly; Secure; SameSite=Lax instead of returning it in the response body, and update get_current_user to read it from the cookie instead of the Authorization header. Explain what new protection (CSRF) this reintroduces and sketch one mitigation.

  • 9. Complete exercise 12 from the React lecture. Add an Axios interceptor (or fetch wrapper) in the TerpTasks frontend that attaches the stored token to every request and, on a 401 response, clears it and redirects to a login screen — without touching any of the existing API call sites. Then demonstrate that a 403 does not trigger the same redirect, and explain why that distinction matters for the user experience.

Summary🔗

Key takeaways🔗
  1. Authentication, authorization, and CORS are three different questions. Who are you, what can you do, and can this browser origin see the response — keep them in separate boxes, and know which one a given failure belongs to.

  2. A JWT’s payload is public; only its signature is trusted. Base64url is an encoding, not encryption — never put secrets in the payload, and treat signature verification as the only thing standing between a legitimate token and a forged one.

  3. Tokens are not automatically better than sessions. They trade a server-side lookup for statelessness and easier scaling, at the cost of harder revocation — pick deliberately, and see the appendix if instant revocation matters.

  4. Where you store a token has real security consequences. localStorage is readable by any script that runs on your page (XSS risk); an HttpOnly cookie is not, but is sent automatically by the browser (CSRF risk). Neither is a free win.

  5. Authentication is a reusable FastAPI dependency; RBAC composes on top of it. Depends(get_current_user) for identity, Depends(require_role(...)) for permission — write each check once.

  6. 401 means “I don’t know you”; 403 means “I know you, and no.” Getting this right keeps your API’s error responses meaningful to every client that has to branch on them.

  7. CORS is enforced by browsers, not by your API. A curl request or another backend ignores it entirely — CORS configuration is not a substitute for authentication or authorization, ever.

Terminology🔗

Term

Definition

Authentication

Establishing who a user is

Authorization

Deciding what an authenticated user may do

JWT

A signed, base64url-encoded token carrying claims (header.payload.signature)

Claim

A statement in a JWT payload, e.g. sub, exp, role

Bearer token

A token that authenticates whoever holds it, no further proof required

Session

Server-side state referenced by an opaque cookie value

RBAC

Role-based access control: permissions attached to roles, not individual users

401 Unauthorized

No valid authenticated identity was established

403 Forbidden

Identity is known; the action is not permitted

Same-origin policy

Browser rule blocking cross-origin script access to responses by default

Origin

scheme + host + port; any difference makes two URLs different origins

CORS

Server-granted, browser-enforced permission for a specific cross-origin request

Preflight

An OPTIONS request the browser sends to ask permission before a non-simple request

XSS

Attacker script executing in your page, e.g. via unsanitized rendered input

CSRF

A malicious page triggering a request that rides on the victim’s existing cookies

Common mistakes🔗
  • Treating a valid CORS configuration as proof the API is secure — it only controls what browsers may read, nothing about who is allowed to ask.

  • Putting a password, API key, or other secret inside a JWT payload because “it’s encoded.”

  • Returning 403 for a missing/invalid token, or 401 for a valid token with the wrong role — swapping the two confuses every client that branches on status code.

  • Storing a JWT in localStorage and calling it “done” with no thought to XSS, or switching to an HttpOnly cookie and forgetting CSRF protections entirely.

  • Combining allow_credentials=True with allow_origins=["*"].

  • Guessing at a CORS fix instead of reading the actual Origin/Access-Control-* headers in DevTools’ Network tab.

  • Forgetting that a missing FK-style check exists here too: an endpoint that authenticates a user but never checks whose task is being edited (Alice can edit Bob’s task by guessing its id) has an authorization hole even with RBAC in place — role checks and ownership checks are not the same thing.

Connections🔗
  • Backward: the REST/FastAPI lecture’s dependency-injection pattern (Depends) is exactly what makes get_current_user/require_role reusable across routes; its status-code discipline is what makes 401 vs.\ 403 matter; the React lecture’s Appendix B introduced CORS as “the full-stack gotcha,” and this lecture is where it gets a full mechanism (same-origin policy, preflight) and a permanent home next to authentication and authorization.

  • Forward: the database lecture’s users table is where authenticate_user really looks up hashed passwords instead of an in-memory dict; the layered-architecture lecture’s service/route split is exactly where get_current_user and require_role live as cross-cutting concerns; and any later deployment work will move SECRET_KEY out of source code and into environment configuration, and allow_origins from localhost to a real production domain.