DIY JWT vs Auth0 in FastAPI

Build a Notes API with two auth systems and compare DIY JWT vs Auth0.

Introduction

30 Second Summary

Every app with user accounts needs authentication. But should you build it yourself or use a managed service that handles the heavy lifting for you?

In this project, you will build a Notes API with two authentication systems running side by side. One is a complete DIY JWT system you build from scratch, and the other uses Auth0, a managed authentication service.

What You'll Build

A FastAPI application with two separate authentication approaches protecting the same resource, letting you directly compare the developer experience, security posture, and complexity.

By the end of this project, you'll have:

  • A complete DIY JWT authentication system with user registration, password hashing, token generation, and protected routes.
  • An Auth0-powered authentication flow with social login via GitHub and automatic session management.
  • A comparison endpoint that returns a structured breakdown of setup time, features, security, and cost for both approaches.
  • Secret Mission: Add role-based access control (RBAC) to your DIY JWT system with admin and user roles.

Are there any prerequisites?

You should have basic Python knowledge and some familiarity with APIs. No prior experience with authentication, JWT tokens, or Auth0 is needed.

Before We Start

Before We Start...

Set Up the Project

Before you can build an authentication system, you need a running application to protect. In this step, you will create the project folder, install the packages that handle password hashing and token generation, and launch a basic FastAPI server.

By the end of this step, your FastAPI app will be live at http://127.0.0.1:8000 with a health check endpoint visible in the interactive Swagger UI.

In this step, get ready to:
  • Create a project folder and Python virtual environment.
  • Install FastAPI, PyJWT, and pwdlib.
  • Write a base FastAPI app with a health check endpoint and in-memory data stores.
Create project folder and virtual environment

Every Python project starts with an isolated virtual environment. This keeps your project's packages separate from any other Python work on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir auth-evaluator
cd auth-evaluator
  • Create a virtual environment using Python 3.13 by running:

macOS

python3.13 -m venv venv

Windows

py -3.13 -m venv venv

What is a virtual environment?

A virtual environment is an isolated copy of Python and its packages. It prevents conflicts between projects that need different package versions.

The venv folder holds this isolated environment. You will never edit files inside it directly.

  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt. This confirms the environment is active.

Don't see (venv) in your prompt?

On Windows PowerShell, you may need to allow script execution first. Run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser and then try the activate command again.

On macOS, double-check you used source before the path. Without it, the shell cannot run the activation script.

Help me activate my virtual environment.

  • Install the project dependencies by running:
pip install "fastapi[standard]" PyJWT "pwdlib[bcrypt]" python-dotenv

What are these packages?

  • fastapi[standard] is the web framework that powers your API, bundled with a dev server.
  • PyJWT creates and verifies JSON Web Tokens for authenticating users.
  • pwdlib[bcrypt] hashes passwords securely using the bcrypt algorithm.
  • python-dotenv loads secrets from a .env file so they stay out of your code.
  • Confirm all packages installed correctly by running:
python -c "import fastapi; import jwt; import pwdlib; print('All packages installed')"

You should see All packages installed printed in the terminal.

Seeing an import error?

Make sure (venv) appears in your terminal prompt. If it does not, the packages installed into the system Python instead of your virtual environment. Reactivate with the command above, then re-run the pip install command.

Help me fix my package installation.

Create the project files

Your project needs three files: the main application, a data models file, and an environment config file for secrets.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the auth-evaluator folder on your Desktop, then click Open.
  • Create models.py by clicking the new file icon in the Explorer sidebar (or press Cmd+N / Ctrl+N) and add the following code:
from pydantic import BaseModel


class UserCreate(BaseModel):
    username: str
    password: str


class User(BaseModel):
    username: str
    hashed_password: str


class UserOut(BaseModel):
    username: str


class Token(BaseModel):
    access_token: str
    token_type: str


class TokenData(BaseModel):
    username: str | None = None


class NoteCreate(BaseModel):
    title: str
    content: str


class Note(BaseModel):
    id: int
    title: str
    content: str
    owner: str

What do these models do?

Pydantic models define the shape of data flowing through your API. FastAPI uses them to automatically validate incoming requests and generate documentation.

  • UserCreate and User handle registration input and stored user data.
  • Token and TokenData define the structure of JWT tokens your app will issue.
  • NoteCreate and Note represent the notes your API will protect.
  • Save models.py with Cmd+S (macOS) or Ctrl+S (Windows).
  • Create a new file called .env and add the following configuration:
SECRET_KEY=your-secret-key-here
AUTH0_DOMAIN=your-tenant.auth0.com
AUTH0_CLIENT_ID=your-client-id-here
AUTH0_CLIENT_SECRET=your-client-secret-here
APP_BASE_URL=http://localhost:8000
SESSION_SECRET=your-session-secret-here

What is this .env file for?

The .env file stores secrets and configuration that should never appear in your source code. The python-dotenv package loads these values into environment variables at runtime.

You will fill in the Auth0 values in a later step. For now, the placeholders keep your app from crashing on startup.

  • Save .env.
  • Create a new file called main.py and add the following code:
import os
from fastapi import FastAPI
from dotenv import load_dotenv

load_dotenv()

SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

app = FastAPI(title="Auth Evaluator", description="Compare DIY JWT vs Auth0 authentication")

# In-memory databases
users_db: dict = {}
notes_db: dict = {}


@app.get("/health")
async def health_check():
    return {"status": "healthy", "auth_methods": ["diy_jwt", "auth0"]}

What does this code do?

  • load_dotenv() reads your .env file and makes its values available through os.getenv().
  • SECRET_KEY, ALGORITHM, and ACCESS_TOKEN_EXPIRE_MINUTES configure JWT token creation in the next step.
  • users_db and notes_db are simple dictionaries that store data in memory. A production app would use a real database, but these keep the focus on authentication logic.
  • The /health endpoint returns a quick status check confirming both auth methods will be available.
  • Save main.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the full content for each file:

from pydantic import BaseModel


class UserCreate(BaseModel):
    username: str
    password: str


class User(BaseModel):
    username: str
    hashed_password: str


class UserOut(BaseModel):
    username: str


class Token(BaseModel):
    access_token: str
    token_type: str


class TokenData(BaseModel):
    username: str | None = None


class NoteCreate(BaseModel):
    title: str
    content: str


class Note(BaseModel):
    id: int
    title: str
    content: str
    owner: str
SECRET_KEY=your-secret-key-here
AUTH0_DOMAIN=your-tenant.auth0.com
AUTH0_CLIENT_ID=your-client-id-here
AUTH0_CLIENT_SECRET=your-client-secret-here
APP_BASE_URL=http://localhost:8000
SESSION_SECRET=your-session-secret-here
import os
from fastapi import FastAPI
from dotenv import load_dotenv

load_dotenv()

SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

app = FastAPI(title="Auth Evaluator", description="Compare DIY JWT vs Auth0 authentication")

# In-memory databases
users_db: dict = {}
notes_db: dict = {}


@app.get("/health")
async def health_check():
    return {"status": "healthy", "auth_methods": ["diy_jwt", "auth0"]}
Start the FastAPI dev server

With all three files saved, you can launch the development server and confirm everything is wired up correctly.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Make sure (venv) appears in the prompt. If it does not, reactivate your virtual environment using the activation command from earlier.
  • Start the development server by running:
fastapi dev main.py

You should see output including a line like Uvicorn running on http://127.0.0.1:8000. This confirms your server is live.

Server not starting?

  • If you see a "module not found" error for fastapi, your virtual environment is not active. Reactivate it and try again.
  • If you see a syntax error, double-check that main.py matches the code above exactly. Python is sensitive to indentation.
  • If port 8000 is already in use, stop the other process or close the terminal using it, then retry.

Help me get my FastAPI server running.

  • Open your browser and navigate to http://127.0.0.1:8000/docs.

You should see the FastAPI Swagger UI page with your app title "Auth Evaluator" and a single GET /health endpoint listed.

  • Click the GET /health row to expand it.
  • Click Try it out, then click Execute.

The response body should show {"status": "healthy", "auth_methods": ["diy_jwt", "auth0"]}.

What is Swagger UI?

FastAPI automatically generates interactive API documentation at the /docs path. You can test every endpoint directly in the browser without tools like Postman or curl.

As you add authentication endpoints in the next steps, they will appear here automatically with login forms built in.

Your project skeleton is running. Next up, you will build the DIY JWT authentication system that protects the notes endpoints with username, password, and tokens.

Build DIY JWT Authentication

Your FastAPI app is running, but right now anyone can access it. Authentication is the process of verifying who a user is before letting them interact with your data.

In this step, you will build a complete JWT authentication system from scratch. This includes user registration with password hashing, login with token generation, and protected API routes that only authenticated users can reach.

In this step, get ready to:
  • Implement user registration with secure password hashing.
  • Build a login endpoint that generates JWT tokens.
  • Protect your notes API with token-based authentication.
Implement user registration and password hashing

Before a user can log in, they need to register. You need to store passwords securely so they cannot be read even if someone accesses the database.

  • In main.py, replace everything above the @app.get("/health") line with the following updated imports and setup:
import os
from datetime import datetime, timedelta, timezone
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from dotenv import load_dotenv
import jwt
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash

from models import Note, NoteCreate, Token, TokenData, User, UserCreate, UserOut

load_dotenv()

SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

app = FastAPI(title="Auth Evaluator", description="Compare DIY JWT vs Auth0 authentication")

# Password hasher using bcrypt
password_hash = PasswordHash.recommended()

# In-memory databases
users_db: dict[str, User] = {}
notes_db: dict[str, list[Note]] = {}

# OAuth2 scheme that tells FastAPI where the token endpoint lives
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")

What changed here?

  • New imports bring in jwt for token encoding/decoding, PasswordHash from pwdlib for hashing, and FastAPI's security utilities.
  • password_hash = PasswordHash.recommended() creates a bcrypt hasher that securely one-way hashes passwords.
  • oauth2_scheme tells FastAPI's Swagger UI where to send login credentials. It also adds an Authorize button to the docs page.
  • The model imports (User, Note, etc.) pull in the Pydantic schemas you already created in models.py.
  • Directly below oauth2_scheme and above the @app.get("/health") endpoint, add the password utility functions:
# --- Password utilities ---
def hash_password(password: str) -> str:
    return password_hash.hash(password)


def verify_password(password: str, hashed: str) -> bool:
    return password_hash.verify(password, hashed)

What does this code do?

hash_password() takes a plain-text password and returns a bcrypt hash. The original password cannot be recovered from this hash.

verify_password() checks if a plain-text password matches a stored hash. This is how you validate login attempts without ever storing the actual password.

  • Below the password utilities and above the health check endpoint, add the registration endpoint:
# --- DIY JWT Auth Endpoints ---
@app.post("/auth/register", response_model=UserOut)
async def register(user_data: UserCreate):
    if user_data.username in users_db:
        raise HTTPException(status_code=400, detail="Username already registered")
    hashed = hash_password(user_data.password)
    user = User(username=user_data.username, hashed_password=hashed)
    users_db[user.username] = user
    notes_db[user.username] = []
    return UserOut(username=user.username)

What does this code do?

  • The endpoint accepts a username and password via the UserCreate model.
  • It checks for duplicate usernames, hashes the password, and stores the user in users_db.
  • It returns only the username (via UserOut) so the hashed password is never exposed in the response.
  • Save main.py.
  • Refresh your browser at http://127.0.0.1:8000/docs. You should see the new POST /auth/register endpoint listed in Swagger UI.
  • Click POST /auth/register, then click Try it out.
  • Enter a test username and password in the request body, for example:
{
  "username": "testuser",
  "password": "mypassword123"
}
  • Click Execute. You should see a 200 response with {"username": "testuser"}.

Not seeing the new endpoints?

  • Make sure you saved main.py. The FastAPI dev server auto-reloads on save.
  • Check your terminal for error messages. A missing import or syntax error will prevent the reload.
  • Confirm that your /auth/register code is placed before or after the health check, not inside it.

Why isn't my registration endpoint showing up in Swagger UI?

Implement login and JWT token generation

Now that users can register, they need a way to log in and prove their identity on subsequent requests. JWT tokens solve this by encoding user information into a signed string that the server can verify later.

  • In main.py, add the token creation function between your password utilities and the # --- DIY JWT Auth Endpoints --- comment:
# --- Token utilities ---
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(minutes=15)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

What does this code do?

  • It copies the input data (like the username) into a new dictionary so the original is not mutated.
  • It adds an exp (expiration) claim so the token automatically becomes invalid after 30 minutes.
  • jwt.encode() signs the payload with your SECRET_KEY using the HS256 algorithm. Anyone can decode the payload, but only your server can verify the signature.
  • Now add the login endpoint directly below the /auth/register endpoint:
@app.post("/auth/token", response_model=Token)
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
):
    user = users_db.get(form_data.username)
    if not user or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
    )
    return Token(access_token=access_token, token_type="bearer")

What does this code do?

  • OAuth2PasswordRequestForm is a FastAPI dependency that collects username and password from form data. This is the standard OAuth2 password flow format.
  • The endpoint verifies the password against the stored hash. If it fails, it returns a 401 Unauthorized response.
  • On success, it creates a JWT with the sub (subject) claim set to the username. This is the standard JWT claim for identifying the token owner.
  • Save main.py and refresh Swagger UI.
  • Click POST /auth/token, then Try it out.
  • Enter testuser in the username field and mypassword123 in the password field.
  • Click Execute. You should receive a response containing an access_token (a long encoded string) and token_type set to bearer.

Getting 401 Unauthorized?

  • Make sure you registered the user first in the previous substep. The in-memory database resets every time the server restarts.
  • Double check that the username and password match exactly what you used during registration.

Why is my login returning 401 even though I registered the user?

Create the get_current_user dependency and protect the notes endpoints

The final piece is a dependency that extracts and validates the JWT from incoming requests. Any endpoint that uses this dependency becomes a protected route.

  • In main.py, add the get_current_user dependency between your create_access_token function and the health check endpoint:
# --- Dependencies ---
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
    credentials_exception = 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])
        username: str | None = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except InvalidTokenError:
        raise credentials_exception
    user = users_db.get(token_data.username)
    if user is None:
        raise credentials_exception
    return user

What does this code do?

  • Depends(oauth2_scheme) automatically extracts the token from the Authorization: Bearer <token> header.
  • jwt.decode() verifies the signature and extracts the payload. If the token is expired or tampered with, it raises InvalidTokenError.
  • The function looks up the user by the sub claim. If anything fails at any point, it returns a 401 response.
  • Now add the create note endpoint after the /auth/token endpoint. Add a section comment first:
# --- Protected Notes Endpoints (DIY JWT) ---
@app.post("/notes", response_model=Note)
async def create_note(
    note_data: NoteCreate,
    current_user: Annotated[User, Depends(get_current_user)],
):
    note = Note(
        id=len(notes_db.get(current_user.username, [])) + 1,
        title=note_data.title,
        content=note_data.content,
        owner=current_user.username,
    )
    notes_db.setdefault(current_user.username, []).append(note)
    return note


@app.get("/notes", response_model=list[Note])
async def list_notes(
    current_user: Annotated[User, Depends(get_current_user)],
):
    return notes_db.get(current_user.username, [])

What does this code do?

  • Both endpoints declare current_user: Annotated[User, Depends(get_current_user)] as a parameter. FastAPI automatically calls get_current_user and injects the result.
  • POST /notes creates a new note owned by the authenticated user.
  • GET /notes returns only the notes belonging to the current user. Users cannot see each other's notes.
  • Add the delete endpoint directly below list_notes:
@app.delete("/notes/{note_id}")
async def delete_note(
    note_id: int,
    current_user: Annotated[User, Depends(get_current_user)],
):
    user_notes = notes_db.get(current_user.username, [])
    for i, note in enumerate(user_notes):
        if note.id == note_id:
            user_notes.pop(i)
            return {"detail": "Note deleted"}
    raise HTTPException(status_code=404, detail="Note not found")

What does this code do?

This endpoint loops through the current user's notes to find the one with the matching note_id. If found, it removes it from the list. If no match exists, it returns a 404 error. The get_current_user dependency ensures only the note's owner can delete it.

  • Save main.py and refresh Swagger UI.
  • First, test without authentication. Click GET /notes, then Try it out, then Execute. You should see a 401 Unauthorized response.
  • Now authenticate. Click the Authorize button at the top of the Swagger UI page.
  • Enter testuser in the username field and mypassword123 in the password field, then click Authorize.
  • Try GET /notes again. You should now see a 200 response with an empty list [].

The server restarts reset in-memory data

If you saved a file and the dev server restarted, your registered user was wiped from memory. Register the user again via POST /auth/register before trying to log in.

Still getting 401 after authorizing?

  • The in-memory database resets on every server restart. If you edited and saved your file, register a new user first, then authorize again.
  • Confirm that tokenUrl in your OAuth2PasswordBearer matches your login endpoint path: /auth/token.
  • Check your terminal for any import errors or tracebacks that prevented the reload.

Why am I still getting 401 after clicking Authorize in Swagger UI?

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and your server is running without errors.

ⓧ I'd like to double check the full code

Here is the complete main.py as it should look at the end of this step:

import os
from datetime import datetime, timedelta, timezone
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from dotenv import load_dotenv
import jwt
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash

from models import Note, NoteCreate, Token, TokenData, User, UserCreate, UserOut

load_dotenv()

SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

app = FastAPI(title="Auth Evaluator", description="Compare DIY JWT vs Auth0 authentication")

password_hash = PasswordHash.recommended()

# In-memory databases
users_db: dict[str, User] = {}
notes_db: dict[str, list[Note]] = {}

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")


# --- Password utilities ---
def hash_password(password: str) -> str:
    return password_hash.hash(password)


def verify_password(password: str, hashed: str) -> bool:
    return password_hash.verify(password, hashed)


# --- Token utilities ---
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(minutes=15)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt


# --- Dependencies ---
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
    credentials_exception = 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])
        username: str | None = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except InvalidTokenError:
        raise credentials_exception
    user = users_db.get(token_data.username)
    if user is None:
        raise credentials_exception
    return user


# --- Health check ---
@app.get("/health")
async def health_check():
    return {"status": "healthy", "auth_methods": ["diy_jwt", "auth0"]}


# --- DIY JWT Auth Endpoints ---
@app.post("/auth/register", response_model=UserOut)
async def register(user_data: UserCreate):
    if user_data.username in users_db:
        raise HTTPException(status_code=400, detail="Username already registered")
    hashed = hash_password(user_data.password)
    user = User(username=user_data.username, hashed_password=hashed)
    users_db[user.username] = user
    notes_db[user.username] = []
    return UserOut(username=user.username)


@app.post("/auth/token", response_model=Token)
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
):
    user = users_db.get(form_data.username)
    if not user or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
    )
    return Token(access_token=access_token, token_type="bearer")


# --- Protected Notes Endpoints (DIY JWT) ---
@app.post("/notes", response_model=Note)
async def create_note(
    note_data: NoteCreate,
    current_user: Annotated[User, Depends(get_current_user)],
):
    note = Note(
        id=len(notes_db.get(current_user.username, [])) + 1,
        title=note_data.title,
        content=note_data.content,
        owner=current_user.username,
    )
    notes_db.setdefault(current_user.username, []).append(note)
    return note


@app.get("/notes", response_model=list[Note])
async def list_notes(
    current_user: Annotated[User, Depends(get_current_user)],
):
    return notes_db.get(current_user.username, [])


@app.delete("/notes/{note_id}")
async def delete_note(
    note_id: int,
    current_user: Annotated[User, Depends(get_current_user)],
):
    user_notes = notes_db.get(current_user.username, [])
    for i, note in enumerate(user_notes):
        if note.id == note_id:
            user_notes.pop(i)
            return {"detail": "Note deleted"}
    raise HTTPException(status_code=404, detail="Note not found")

You now have a fully working DIY authentication system. Users can register, log in, and access protected resources. Next up, you will integrate Auth0 to see how a managed service handles the same problem with far less code.

Integrate Auth0 Managed Authentication

You now have a working DIY JWT authentication system. It handles registration, login, and protected routes. But you built everything from scratch.

What if you could get social login, session management, and a professional login page without writing any of that code yourself? That is what a managed authentication service provides. In this step, you will integrate Auth0 alongside your DIY system so you can compare both approaches directly.

In this step, get ready to:
  • Create and configure an Auth0 application with callback URLs and GitHub social login.
  • Install the Auth0 FastAPI SDK and wire it into your app.
  • Build Auth0-protected notes endpoints and test the login flow.
Create an Auth0 account and configure the application

Auth0 is a managed identity platform. Instead of building login flows yourself, Auth0 provides a hosted login page, social connections, and session handling out of the box.

  • Open your browser and go to https://auth0.com.
  • Click Sign Up and create a free account. The free tier supports up to 25,000 monthly active users.
  • Once you land on the Auth0 Dashboard, click Applications in the left sidebar.
  • Click Applications again in the submenu.
  • Click Create Application.
  • Enter a name for your application (for example, Auth Evaluator).
  • Select Regular Web Applications as the application type.
  • Click Create.

You will land on the application's Quick Start page. Now you need to configure the URLs that Auth0 uses for redirects.

  • Click the Settings tab.
  • Scroll down to the Application URIs section.
  • In the Allowed Callback URLs field, enter http://localhost:8000/auth/callback.
  • In the Allowed Logout URLs field, enter http://localhost:8000.
  • Scroll down and click Save Changes.

What are callback URLs?

After a user logs in through Auth0's hosted page, Auth0 needs to know where to send them back. The callback URL is where your app receives the authentication response and finishes the login flow.

The logout URL tells Auth0 where to redirect after the user signs out.

Next, enable GitHub as a social login option so users can sign in with their GitHub account.

  • In the left sidebar, click Authentication.
  • Click Social.
  • If GitHub is not already listed, click Create Connection and select GitHub.
  • Accept the default settings and click Create.
  • On the connection page, click the Applications tab.
  • Toggle the switch to enable this connection for your Auth Evaluator application.

Finally, record your Auth0 credentials in your project's environment file.

  • Return to your application's Settings tab (click Applications in the sidebar, then click your app name, then Settings).
  • Note the Domain, Client ID, and Client Secret values at the top of the page.
  • Switch back to VS Code and open your .env file.
  • Replace the placeholder values for AUTH0_DOMAIN, AUTH0_CLIENT_ID, and AUTH0_CLIENT_SECRET with the values from your Auth0 dashboard.
  • Set SESSION_SECRET to any random string (this encrypts the session cookie).

What does your .env file look like?

Your .env already has lines for AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, APP_BASE_URL, and SESSION_SECRET from the previous step. You are filling in the real values now.

Not sure where to find these values?

The Domain looks like your-tenant.us.auth0.com. The Client ID and Client Secret are long alphanumeric strings. All three appear at the top of your application's Settings tab in the Auth0 dashboard.

Help me find my Auth0 credentials

Install and configure the Auth0 FastAPI SDK

The auth0-fastapi SDK handles the login, logout, and callback routes automatically. You just configure it with your credentials and register the routes.

  • Install the Auth0 FastAPI SDK by running this command in your terminal:
pip install auth0-fastapi

What is auth0-fastapi?

This is the official Auth0-maintained SDK for session-based FastAPI web applications. It provides pre-built login, logout, and callback route handlers that integrate with Auth0's Universal Login page.

Install failed?

Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If not, activate it first.

Help me troubleshoot the auth0-fastapi install

Now wire the SDK into your FastAPI app. You need to add SessionMiddleware (required for cookie-based sessions), create the Auth0 configuration, and register the auth routes.

  • In main.py, find the import block at the top of the file. Add these two new imports below the existing ones:
from fastapi.middleware.sessions import SessionMiddleware
from auth0_fastapi import Auth0Config, auth0_router, get_user

What do these imports do?

SessionMiddleware adds cookie-based session support to FastAPI. The Auth0 SDK stores authentication state in session cookies, so this middleware must be in place before the SDK can work.

Auth0Config holds your Auth0 credentials. auth0_router generates pre-built login/logout/callback routes. get_user is a dependency that retrieves the logged-in user from the session.

  • Next, find the line that creates the FastAPI app:
app = FastAPI(title="Auth Evaluator", description="Compare DIY JWT vs Auth0 authentication")
  • Directly below that line, add the SessionMiddleware and Auth0 configuration:
# Add cookie-based session support for Auth0
app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET", "session-secret"))

# Configure Auth0 with your tenant credentials
auth0_config = Auth0Config(
    domain=os.getenv("AUTH0_DOMAIN"),
    client_id=os.getenv("AUTH0_CLIENT_ID"),
    client_secret=os.getenv("AUTH0_CLIENT_SECRET"),
    app_base_url=os.getenv("APP_BASE_URL", "http://localhost:8000"),
)

# Register the Auth0 login/logout/callback routes
app.include_router(auth0_router(auth0_config))

What does this code do?

app.add_middleware(SessionMiddleware, ...) enables cookie sessions using your SESSION_SECRET as the encryption key.

Auth0Config bundles your Auth0 tenant details into one object. The SDK reads these to know where to redirect users for login.

app.include_router(auth0_router(auth0_config)) registers the /auth/login, /auth/logout, and /auth/callback routes automatically.

  • Find the line that says notes_db: dict[str, list[Note]] = {} and add a new in-memory store for Auth0 notes directly below it:
# Separate notes store for Auth0-authenticated users
auth0_notes_db: dict[str, list[Note]] = {}
  • Save main.py.
  • If your dev server is still running, stop it with Ctrl+C and restart it by running:
fastapi dev main.py
  • Open your browser and visit http://localhost:8000/auth/login.

You should see the Auth0 Universal Login page with options to sign up, log in, or continue with GitHub.

Seeing an error instead of the login page?

Check that your .env file has the correct AUTH0_DOMAIN value. It should match the Domain shown in your Auth0 application's Settings tab (for example, dev-abc123.us.auth0.com).

Also verify your Allowed Callback URLs in Auth0 is set to exactly http://localhost:8000/auth/callback with no trailing slash.

Help me debug my Auth0 login page error

Create Auth0-protected endpoints and compare

Now that the Auth0 login flow works, you need a dependency that checks whether the user has an active Auth0 session. Then you will create notes endpoints protected by that dependency.

  • In main.py, find the get_current_user function. Add the new get_auth0_user dependency directly below it (after the function's closing return user line):
# Auth0 session-based dependency
async def get_auth0_user(user=Depends(get_user)):
    if not user:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    return user

What does this dependency do?

get_user (from the Auth0 SDK) checks the session cookie for an authenticated user. If no session exists, it returns None. Your get_auth0_user wrapper raises a 401 error in that case, making it easy to protect any route.

  • Scroll to the bottom of main.py (after the delete_note endpoint). Add the Auth0-protected notes endpoints:
# --- Auth0-Protected Notes Endpoints ---
@app.get("/auth0/notes")
async def list_auth0_notes(user=Depends(get_auth0_user)):
    user_id = user.get("sub", "unknown")
    return auth0_notes_db.get(user_id, [])


@app.post("/auth0/notes")
async def create_auth0_note(note_data: NoteCreate, user=Depends(get_auth0_user)):
    user_id = user.get("sub", "unknown")
    note = Note(
        id=len(auth0_notes_db.get(user_id, [])) + 1,
        title=note_data.title,
        content=note_data.content,
        owner=user_id,
    )
    auth0_notes_db.setdefault(user_id, []).append(note)
    return note

What does this code do?

These endpoints mirror the DIY JWT notes endpoints but use Auth0 session-based authentication instead. The user.get("sub") extracts the unique user identifier from the Auth0 session (similar to the sub claim in a JWT).

By having both sets of endpoints in the same app, you can directly compare how each authentication method feels to implement and use.

  • Save main.py.
  • Restart your dev server (Ctrl+C, then run fastapi dev main.py).
  • Open http://localhost:8000/auth/login in your browser.
  • Click Continue with GitHub to log in via your GitHub account.
  • After the redirect, visit http://localhost:8000/auth0/notes in your browser.

You should see an empty list [] confirming you are authenticated and can access the protected endpoint.

Getting a 401 error on /auth0/notes?

Make sure you completed the login flow first by visiting /auth/login and logging in with GitHub. The session cookie is set during the callback redirect.

If you see a callback mismatch error during login, double-check that http://localhost:8000/auth/callback is in your Auth0 Allowed Callback URLs field with no trailing slash.

Help me debug my Auth0 session issue

Why managed auth instead of DIY?

With DIY JWT, you wrote password hashing, token generation, and validation code. With Auth0, those lines disappeared. You got social login (GitHub) for free, plus a professional login UI, session management, and built-in security features like brute-force protection.

The trade-off is control. With DIY, you own every decision. With managed auth, you rely on the provider's implementation. This project lets you feel both sides firsthand.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that your server is running without errors.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

import os
from datetime import datetime, timedelta, timezone
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.middleware.sessions import SessionMiddleware
from dotenv import load_dotenv
import jwt
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from auth0_fastapi import Auth0Config, auth0_router, get_user

from models import Note, NoteCreate, Token, TokenData, User, UserCreate, UserOut

load_dotenv()

SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

app = FastAPI(title="Auth Evaluator", description="Compare DIY JWT vs Auth0 authentication")

app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET", "session-secret"))

auth0_config = Auth0Config(
    domain=os.getenv("AUTH0_DOMAIN"),
    client_id=os.getenv("AUTH0_CLIENT_ID"),
    client_secret=os.getenv("AUTH0_CLIENT_SECRET"),
    app_base_url=os.getenv("APP_BASE_URL", "http://localhost:8000"),
)

app.include_router(auth0_router(auth0_config))

password_hash = PasswordHash.recommended()

# In-memory databases
users_db: dict[str, User] = {}
notes_db: dict[str, list[Note]] = {}
auth0_notes_db: dict[str, list[Note]] = {}

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")


# --- Password utilities ---
def hash_password(password: str) -> str:
    return password_hash.hash(password)


def verify_password(password: str, hashed: str) -> bool:
    return password_hash.verify(password, hashed)


# --- Token utilities ---
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(minutes=15)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt


# --- Dependencies ---
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
    credentials_exception = 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])
        username: str | None = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except InvalidTokenError:
        raise credentials_exception
    user = users_db.get(token_data.username)
    if user is None:
        raise credentials_exception
    return user


async def get_auth0_user(user=Depends(get_user)):
    if not user:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    return user


# --- Health check ---
@app.get("/health")
async def health_check():
    return {"status": "healthy", "auth_methods": ["diy_jwt", "auth0"]}


# --- DIY JWT Auth Endpoints ---
@app.post("/auth/register", response_model=UserOut)
async def register(user_data: UserCreate):
    if user_data.username in users_db:
        raise HTTPException(status_code=400, detail="Username already registered")
    hashed = hash_password(user_data.password)
    user = User(username=user_data.username, hashed_password=hashed)
    users_db[user.username] = user
    notes_db[user.username] = []
    return UserOut(username=user.username)


@app.post("/auth/token", response_model=Token)
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
):
    user = users_db.get(form_data.username)
    if not user or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
    )
    return Token(access_token=access_token, token_type="bearer")


# --- Protected Notes Endpoints (DIY JWT) ---
@app.post("/notes", response_model=Note)
async def create_note(
    note_data: NoteCreate,
    current_user: Annotated[User, Depends(get_current_user)],
):
    note = Note(
        id=len(notes_db.get(current_user.username, [])) + 1,
        title=note_data.title,
        content=note_data.content,
        owner=current_user.username,
    )
    notes_db.setdefault(current_user.username, []).append(note)
    return note


@app.get("/notes", response_model=list[Note])
async def list_notes(
    current_user: Annotated[User, Depends(get_current_user)],
):
    return notes_db.get(current_user.username, [])


@app.delete("/notes/{note_id}")
async def delete_note(
    note_id: int,
    current_user: Annotated[User, Depends(get_current_user)],
):
    user_notes = notes_db.get(current_user.username, [])
    for i, note in enumerate(user_notes):
        if note.id == note_id:
            user_notes.pop(i)
            return {"detail": "Note deleted"}
    raise HTTPException(status_code=404, detail="Note not found")


# --- Auth0-Protected Notes Endpoints ---
@app.get("/auth0/notes")
async def list_auth0_notes(user=Depends(get_auth0_user)):
    user_id = user.get("sub", "unknown")
    return auth0_notes_db.get(user_id, [])


@app.post("/auth0/notes")
async def create_auth0_note(note_data: NoteCreate, user=Depends(get_auth0_user)):
    user_id = user.get("sub", "unknown")
    note = Note(
        id=len(auth0_notes_db.get(user_id, [])) + 1,
        title=note_data.title,
        content=note_data.content,
        owner=user_id,
    )
    auth0_notes_db.setdefault(user_id, []).append(note)
    return note

You now have two authentication systems running side by side in the same application. Next, you will test them both together and build a comparison endpoint that summarizes the trade-offs.

Compare and Evaluate Both Approaches

You now have two fully working authentication systems in the same application. The DIY JWT flow handles registration, login, and token-based protection. The Auth0 flow handles social login, session management, and cookie-based protection.

In this step, you will put both systems through their paces and then create a comparison endpoint that documents the trade-offs you experienced firsthand.

In this step, get ready to:
  • Test both authentication flows side by side.
  • Create a comparison endpoint summarizing the trade-offs.
Test both authentication flows side by side

You have both auth systems running. Now you will exercise each one end-to-end so you can feel the developer experience difference between managing auth yourself and delegating it to a service.

Test the DIY JWT flow via Swagger UI
  • Make sure your dev server is running. If it is not, start it by running:
  • Open your browser and navigate to http://127.0.0.1:8000/docs to open the Swagger UI.
fastapi dev main.py
  • Expand the POST /auth/register endpoint and click Try it out.
  • Enter a test username and password in the request body, then click Execute.
{
  "username": "testuser",
  "password": "testpass123"
}
  • You should see a 200 response with your username returned.
  • Now click the Authorize button at the top of the Swagger UI page.
  • Enter testuser as the username and testpass123 as the password, then click Authorize.
  • Close the dialog. The lock icons on your /notes endpoints should now show as locked (authenticated).
  • Expand POST /notes, click Try it out, and create a note:
{
  "title": "My first note",
  "content": "Created with DIY JWT auth"
}
  • Click Execute. You should see a 200 response with your note including an id and owner field.
  • Expand GET /notes and click Execute. Your note appears in the list.

What just happened?

You walked through the entire DIY JWT lifecycle: register a user, get a token (Swagger handled this via the Authorize dialog), and access protected resources with that token. Every piece of this flow is code you wrote yourself.

Test the Auth0 flow via the browser
  • Open a new browser tab and navigate to http://localhost:8000/auth/login.
  • You will be redirected to the Auth0 Universal Login page.
  • Click Continue with GitHub to log in with your GitHub account.
  • After successful login, you are redirected back to your app.
  • Navigate to http://localhost:8000/auth0/notes to confirm you can access Auth0-protected notes.

What does each approach handle?

With DIY JWT, you handled password storage, hashing, token creation, token validation, and token expiry yourself. With Auth0, all of that plus social login, session cookies, and the login UI were handled for you.

The trade-off is clear: Auth0 gives you more out of the box, but you hand over control. DIY JWT gives you full control, but you are responsible for every security detail.

Create a comparison endpoint

Now that you have tested both approaches, you will add a /compare endpoint that returns a structured JSON comparison. This captures what you learned in a format you (or a team) can reference later.

You will also clean up the Auth0 integration code from main.py. The Auth0 flow served its purpose as a hands-on evaluation. Going forward, the codebase keeps the DIY JWT system and documents the comparison.

Remove the Auth0 integration code
  • In main.py, delete the following import lines near the top of the file:
from fastapi.middleware.sessions import SessionMiddleware
from auth0_fastapi import Auth0Config, auth0_router, get_user
  • Delete the SessionMiddleware line that appears after the app = FastAPI(...) line:
app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET", "session-secret"))
  • Delete the auth0_config block and the include_router line:
auth0_config = Auth0Config(
    domain=os.getenv("AUTH0_DOMAIN"),
    client_id=os.getenv("AUTH0_CLIENT_ID"),
    client_secret=os.getenv("AUTH0_CLIENT_SECRET"),
    app_base_url=os.getenv("APP_BASE_URL", "http://localhost:8000"),
)

app.include_router(auth0_router(auth0_config))
  • Delete the auth0_notes_db line from the in-memory databases section:
auth0_notes_db: dict[str, list[Note]] = {}
  • Delete the get_auth0_user dependency function:
async def get_auth0_user(user=Depends(get_user)):
    if not user:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    return user
  • Delete the entire Auth0-Protected Notes section (both endpoints):
# --- Auth0-Protected Notes Endpoints ---
@app.get("/auth0/notes")
async def list_auth0_notes(user=Depends(get_auth0_user)):
    user_id = user.get("sub", "unknown")
    return auth0_notes_db.get(user_id, [])


@app.post("/auth0/notes")
async def create_auth0_note(note_data: NoteCreate, user=Depends(get_auth0_user)):
    user_id = user.get("sub", "unknown")
    note = Note(
        id=len(auth0_notes_db.get(user_id, [])) + 1,
        title=note_data.title,
        content=note_data.content,
        owner=user_id,
    )
    auth0_notes_db.setdefault(user_id, []).append(note)
    return note

Why remove the Auth0 code?

The Auth0 integration served as a hands-on comparison tool. Now that you have experienced both approaches, the codebase focuses on the DIY JWT system with a comparison endpoint that documents your findings.

In a real project, you would pick one approach and commit to it. The comparison endpoint captures the decision criteria so you (or your team) can justify the choice later.

Add the comparison endpoint
  • In main.py, add the following comparison endpoint at the bottom of the file, after the delete_note function:
# --- Comparison Endpoint ---
@app.get("/compare")
async def compare_auth_methods():
    return {
        "diy_jwt": {
            "setup_time": "~20 minutes",
            "cost": "Free forever",
            "features": [
                "Username/password registration",
                "JWT token generation",
                "Protected routes",
                "Full control over logic",
            ],
            "you_must_build": [
                "Password reset flow",
                "Email verification",
                "Social login (OAuth2 flows)",
                "MFA/2FA",
                "Rate limiting",
                "Account lockout",
            ],
            "best_for": "APIs, microservices, learning, full control needed",
        },
        "auth0": {
            "setup_time": "~15 minutes",
            "cost": "Free up to 25,000 MAU",
            "features": [
                "Social login (GitHub, Google, etc.)",
                "Session management",
                "MFA built-in",
                "Password reset built-in",
                "Anomaly detection",
                "Universal Login page",
            ],
            "you_must_build": [
                "Nothing for basic auth - it is handled",
            ],
            "best_for": "Consumer apps, social login, rapid prototyping, compliance needs",
        },
    }

What does this endpoint do?

The /compare endpoint returns a structured JSON object comparing the two approaches across setup time, cost, features included out of the box, features you must build yourself, and ideal use cases.

The you_must_build list for DIY JWT is the key insight. To match what Auth0 provides, you would need to build six additional systems on top of what you already have.

  • Save main.py.
  • Your dev server should auto-reload. Navigate to http://127.0.0.1:8000/compare in your browser.
  • You should see a JSON response with diy_jwt and auth0 keys, each containing the structured comparison data.

Seeing an import error on reload?

If you see an ImportError referencing auth0_fastapi or SessionMiddleware, double-check that you removed all the Auth0-related import lines at the top of main.py. The get_auth0_user dependency also references get_user from that package, so make sure it is gone too.

help me fix an import error after removing Auth0 code from my FastAPI app

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that the server reloaded without errors.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

import os
from datetime import datetime, timedelta, timezone
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from dotenv import load_dotenv
import jwt
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash

from models import Note, NoteCreate, Token, TokenData, User, UserCreate, UserOut

load_dotenv()

SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

app = FastAPI(title="Auth Evaluator", description="Compare DIY JWT vs Auth0 authentication")

password_hash = PasswordHash.recommended()

# In-memory databases
users_db: dict[str, User] = {}
notes_db: dict[str, list[Note]] = {}

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")


# --- Password utilities ---
def hash_password(password: str) -> str:
    return password_hash.hash(password)


def verify_password(password: str, hashed: str) -> bool:
    return password_hash.verify(password, hashed)


# --- Token utilities ---
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(minutes=15)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt


# --- Dependencies ---
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
    credentials_exception = 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])
        username: str | None = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except InvalidTokenError:
        raise credentials_exception
    user = users_db.get(token_data.username)
    if user is None:
        raise credentials_exception
    return user


# --- Health check ---
@app.get("/health")
async def health_check():
    return {"status": "healthy", "auth_methods": ["diy_jwt", "auth0"]}


# --- DIY JWT Auth Endpoints ---
@app.post("/auth/register", response_model=UserOut)
async def register(user_data: UserCreate):
    if user_data.username in users_db:
        raise HTTPException(status_code=400, detail="Username already registered")
    hashed = hash_password(user_data.password)
    user = User(username=user_data.username, hashed_password=hashed)
    users_db[user.username] = user
    notes_db[user.username] = []
    return UserOut(username=user.username)


@app.post("/auth/token", response_model=Token)
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
):
    user = users_db.get(form_data.username)
    if not user or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
    )
    return Token(access_token=access_token, token_type="bearer")


# --- Protected Notes Endpoints (DIY JWT) ---
@app.post("/notes", response_model=Note)
async def create_note(
    note_data: NoteCreate,
    current_user: Annotated[User, Depends(get_current_user)],
):
    note = Note(
        id=len(notes_db.get(current_user.username, [])) + 1,
        title=note_data.title,
        content=note_data.content,
        owner=current_user.username,
    )
    notes_db.setdefault(current_user.username, []).append(note)
    return note


@app.get("/notes", response_model=list[Note])
async def list_notes(
    current_user: Annotated[User, Depends(get_current_user)],
):
    return notes_db.get(current_user.username, [])


@app.delete("/notes/{note_id}")
async def delete_note(
    note_id: int,
    current_user: Annotated[User, Depends(get_current_user)],
):
    user_notes = notes_db.get(current_user.username, [])
    for i, note in enumerate(user_notes):
        if note.id == note_id:
            user_notes.pop(i)
            return {"detail": "Note deleted"}
    raise HTTPException(status_code=404, detail="Note not found")


# --- Comparison Endpoint ---
@app.get("/compare")
async def compare_auth_methods():
    return {
        "diy_jwt": {
            "setup_time": "~20 minutes",
            "cost": "Free forever",
            "features": [
                "Username/password registration",
                "JWT token generation",
                "Protected routes",
                "Full control over logic",
            ],
            "you_must_build": [
                "Password reset flow",
                "Email verification",
                "Social login (OAuth2 flows)",
                "MFA/2FA",
                "Rate limiting",
                "Account lockout",
            ],
            "best_for": "APIs, microservices, learning, full control needed",
        },
        "auth0": {
            "setup_time": "~15 minutes",
            "cost": "Free up to 25,000 MAU",
            "features": [
                "Social login (GitHub, Google, etc.)",
                "Session management",
                "MFA built-in",
                "Password reset built-in",
                "Anomaly detection",
                "Universal Login page",
            ],
            "you_must_build": [
                "Nothing for basic auth - it is handled",
            ],
            "best_for": "Consumer apps, social login, rapid prototyping, compliance needs",
        },
    }

You now have a working comparison that documents the strengths and gaps of each approach. Next up, you will take this knowledge further with a Secret Mission that adds role-based access control to your DIY system.

Secret mission

Add Role-Based Access Control (RBAC)

Your authentication system verifies who a user is. But what about controlling what they can do? In this secret mission, you'll add role-based access control to your DIY JWT system, creating admin and regular user roles with different permissions.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Your Auth0 application sits within the free tier (25,000 MAU) and will not generate charges.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • auth-evaluator project folder on your Desktop (includes virtual environment and installed packages).
  • Auth0 application (the Regular Web Application you created in the Auth0 Dashboard).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your authentication flows.

  • Your project folder stays on your Desktop.
  • Your Auth0 application stays configured and ready for future use.
  • The FastAPI dev server is only running while your terminal is open. Closing the terminal stops it automatically.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and Auth0 configuration stay intact for when you return.

  • Stop the FastAPI dev server by pressing Ctrl+C in the terminal where it is running.
  • Deactivate your virtual environment by running:
deactivate

When you return, reactivate the virtual environment and start the dev server again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your local code and removes the Auth0 application.

  • Stop the FastAPI dev server by pressing Ctrl+C in the terminal.
  • Deactivate your virtual environment by running:
deactivate
  • Delete the project folder by running this on macOS/Linux:
cd ~/Desktop
rm -rf auth-evaluator
  • On Windows, run:
cd ~\Desktop
rmdir /s /q auth-evaluator
  • Delete your Auth0 application: log in to https://manage.auth0.com, open Applications > Applications in the sidebar, select your application, scroll to the bottom of the Settings tab, and click Delete.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-style authentication system from scratch and compared it directly against a managed service.

You've learned how to:

  • Build a complete DIY JWT authentication system from scratch, including user registration, password hashing, token generation, and protected routes.
  • Integrate Auth0 managed authentication with social login via GitHub into the same FastAPI application.
  • Compare both approaches side by side to evaluate setup complexity, features, security, and cost so you can confidently choose the right method for your real applications.
  • Secret Mission: Added role-based access control to the DIY system, demonstrating how authorization extends authentication.

Ready to quiz yourself?