Secure a Python REST API with JWT
Build a FastAPI app with JWT authentication and Argon2 password hashing.
Introduction
30 Second Summary
Every time you log into an app, a system is quietly verifying who you are before letting you access your data. Without that verification, anyone could read your messages, edit your profile, or delete your files.
In this project, you will build a secure REST API that uses JWT tokens to protect sensitive operations like creating, editing, and deleting data. You will also implement password hashing so user credentials are stored safely.
What You'll Build
A secure book collection REST API built with FastAPI that uses JWT authentication and Argon2 password hashing to protect endpoints.
By the end of this project, you'll have:
- A working REST API with public and protected endpoints that require JWT authentication.
- A secure user authentication system with password hashing using the Argon2 algorithm.
- Hands-on experience testing API security through FastAPI's interactive Swagger UI documentation.
- Secret Mission: Add Role-Based Access Control so only admin users can delete books.
Are there any prerequisites?
You need basic Python knowledge (functions, classes, and dictionaries). No prior REST API or security experience is required.
Before We Start
Before We Start...
Set Up Your Python Environment
Before you can build a secure API, you need a working Python environment with the right packages installed.
In this step, you'll install Python, create an isolated project environment, and verify everything works by running a minimal FastAPI app.
In this step, get ready to:
- Install Python and create a virtual environment.
- Install the project dependencies.
- Create and run a minimal FastAPI hello-world app.
Install Python and create your project folder
This project requires Python 3.10 or higher because both FastAPI and pwdlib depend on features introduced in that version.
- Check your Python version by running this command:
macOS
python3 --version
Windows
python --version
✔️ I see version 3.10 or higher
You're all set. Move on to the next instruction below.
ⓧ I see an older version
Your system has Python installed, but it's an older version that won't work with this project's dependencies.
- Download and install the latest Python from python.org/downloads.
- On macOS, the system python3 may still point to the old version after installing a new one. If python3 --version still shows the old version, use the full path to the new binary (e.g. /Library/Frameworks/Python.framework/Versions/3.12/bin/python3) or install via a version manager like pyenv.
- After installing, close and reopen your terminal, then run the version check again.
Use pyenv for easier version management
If you work with multiple Python projects, pyenv lets you install and switch between Python versions easily. Visit github.com/pyenv/pyenv for installation instructions.
ⓧ Command not found
Python is not installed on your system yet.
- Download and install Python from python.org/downloads.
- On Windows, make sure to check the Add Python to PATH checkbox during installation.
- After installing, close and reopen your terminal, then run the version check again.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir secure-book-api
cd secure-book-api
- Confirm the folder exists by running:
ls
You should see that you're now inside an empty secure-book-api folder on your Desktop.
A virtual environment isolates your project's packages from the rest of your system. This prevents version conflicts between different projects.
- Create and activate a virtual environment by running these commands:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
What does this do?
The first command (python3 -m venv venv) creates a folder called venv containing a fresh, isolated Python installation.
The second command activates it so all pip install commands go into this isolated environment instead of your system Python.
After activation, you should see (venv) at the start of your terminal prompt.
Don't see (venv) in your prompt?
Make sure you ran the activate command (not just the venv creation command). On Windows, if you get an execution policy error in PowerShell, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned first, then try activating again.
help me activate my Python virtual environment
- 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 secure-book-api folder on your Desktop, then click Open.
Install the project dependencies
Your API needs four packages. Each one handles a different part of the security stack.
- Install all four dependencies by running this command in your terminal (make sure (venv) is still showing in your prompt):
pip install "fastapi[standard]" pyjwt "pwdlib[argon2]" python-multipart
What are these packages?
- fastapi[standard] installs FastAPI plus its recommended server (uvicorn) and CLI tools.
- pyjwt handles creating and verifying JWT (JSON Web Tokens) for authentication.
- pwdlib[argon2] provides secure password hashing using the Argon2 algorithm.
- python-multipart enables form data parsing, which is required for the login endpoint.
You should see output showing each package being downloaded and installed, ending with a "Successfully installed" message listing all packages.
Seeing installation errors?
Make sure your virtual environment is activated (you see (venv) in your prompt). If you see permission errors without (venv) active, you're installing into your system Python instead of the virtual environment.
help me fix pip install errors in my virtual environment
Create and run a minimal FastAPI app
Before building the full secure API, you'll create a minimal app to verify everything is wired up correctly.
- Create a new file called main.py in the secure-book-api folder by clicking the New File icon in the VS Code Explorer sidebar.
- Name the file main.py and press Enter.
- Copy the following code into main.py:
from fastapi import FastAPI
# Create the FastAPI application instance
app = FastAPI()
# A simple public endpoint to verify the server is running
@app.get("/")
async def root():
return {"message": "Hello World"}
What does this code do?
- from fastapi import FastAPI imports the main class from the FastAPI framework.
- app = FastAPI() creates an application instance that will handle incoming requests.
- @app.get("/") registers the function below as the handler for GET requests to the root URL.
- The function returns a Python dictionary, which FastAPI automatically converts to a JSON response.
- Save main.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Start the development server by running this command in your terminal:
fastapi dev main.py
What does this command do?
The fastapi dev command starts a local development server using uvicorn. It watches for file changes and automatically reloads the server when you save main.py.
You should see output in the terminal that includes a line saying the app is running at http://127.0.0.1:8000.
- Open your web browser and go to http://127.0.0.1:8000.
You should see a JSON response: {"message":"Hello World"}.
Don't see the JSON response?
Make sure the server is still running in your terminal (you should see log output, not a command prompt). If you see an error about the port being in use, another process is using port 8000. Stop it or restart your terminal.
Also confirm that main.py is saved and located directly inside the secure-book-api folder (not in a subfolder).
help me get my FastAPI development server running
✔️ Awesome, I've got everything!
Great. Double check you've saved your file, then keep the server running for now.
ⓧ I'd like to double check the full code
Here's the complete main.py at this point:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"message": "Hello World"}
Your development environment is ready and your first FastAPI app is running. Next up, you'll build out the public API with real data models and book endpoints.
Build the Public API with Pydantic Models
Your environment is set up and your dependencies are installed. Now it's time to build the actual API.
Every API needs a clear contract that defines what data looks like as it flows in and out. In this step, you'll define that contract using Pydantic models, set up sample data, and create your first public endpoints that anyone can access without logging in.
In this step, get ready to:
- Define Pydantic data models for books, users, and authentication tokens.
- Set up an in-memory fake database with sample books and a user.
- Create public GET endpoints and test them in Swagger UI.
Define your imports and password hashing setup
Before building the API logic, you need to import the libraries you installed and configure the password hashing tool. Some of these imports will be used in later steps, but adding them all now keeps the file organized.
- Open main.py in your editor.
- Select all the existing code and delete it.
- Replace it with the following imports and password hashing configuration:
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from pydantic import BaseModel
# --- Password Hashing ---
password_hash = PasswordHash.recommended()
What does this code do?
The first few lines import Python's built-in date/time utilities that you'll use for token expiration later.
- jwt is the PyJWT library for creating and verifying JSON Web Tokens.
- Depends, HTTPException, and status are FastAPI utilities for dependency injection, error handling, and HTTP status codes.
- OAuth2PasswordBearer and OAuth2PasswordRequestForm are FastAPI's built-in security helpers for the OAuth2 password flow.
- PasswordHash.recommended() from pwdlib creates a password hasher using the Argon2 algorithm, the current industry standard for password hashing.
Add the Pydantic data models
Pydantic models define the exact shape of data your API accepts and returns. FastAPI uses them to automatically validate incoming requests and generate documentation.
- Add the following model classes below your password hashing setup in main.py:
# --- Pydantic Models ---
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
class User(BaseModel):
username: str
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
class UserInDB(User):
hashed_password: str
class Book(BaseModel):
id: int
title: str
author: str
year: int
class BookCreate(BaseModel):
title: str
author: str
year: int
What do these models represent?
- Token defines the shape of the JWT response the login endpoint will return.
- TokenData holds the decoded username extracted from a token.
- User is the public-facing user model (no password exposed). UserInDB extends it with the hashed password for internal use.
- Book represents a book returned from the API (includes id). BookCreate is what clients send when adding a new book (no id because the server assigns it).
Next, you need sample data to work with. Instead of setting up a database, you'll use simple Python data structures.
- Add the fake database and the FastAPI app instance below your models in main.py:
# --- Fake Database ---
fake_users_db = {
"johndoe": {
"username": "johndoe",
"full_name": "John Doe",
"email": "johndoe@example.com",
"hashed_password": password_hash.hash("secret"),
"disabled": False,
}
}
fake_books_db = [
{"id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "year": 1925},
{"id": 2, "title": "To Kill a Mockingbird", "author": "Harper Lee", "year": 1960},
{"id": 3, "title": "1984", "author": "George Orwell", "year": 1949},
]
# --- App ---
app = FastAPI(title="Secure Book Collection API")
What does this code do?
- fake_users_db stores a single user named johndoe with a hashed version of the password secret. The password is hashed at startup using Argon2 so you never store a plain-text password.
- fake_books_db is a list of three sample books that the public endpoints will return.
- FastAPI(title="Secure Book Collection API") creates the app instance with a title that appears in the auto-generated documentation.
Why a fake database?
Using an in-memory dictionary and list keeps this project focused on security concepts. A production app would use a real database like PostgreSQL, but the authentication patterns you learn here work exactly the same way regardless of where data is stored.
Create public GET endpoints
Public endpoints are accessible to anyone without authentication. These let users browse the book collection freely.
- Add the following endpoint functions at the bottom of main.py:
# --- Public Endpoints ---
@app.get("/books", response_model=list[Book])
async def get_books():
return fake_books_db
@app.get("/books/{book_id}", response_model=Book)
async def get_book(book_id: int):
for book in fake_books_db:
if book["id"] == book_id:
return book
raise HTTPException(status_code=404, detail="Book not found")
What do these endpoints do?
- GET /books returns the full list of books. The response_model=list[Book] tells FastAPI to validate and serialize the response using the Book model.
- GET /books/{book_id} returns a single book by its ID. If no book matches, it raises a 404 error with a clear message.
- The book_id: int type hint tells FastAPI to automatically parse the URL parameter as an integer and return a validation error if someone passes a non-integer.
- Save main.py.
Seeing red underlines in your editor?
If your editor shows import errors, make sure your virtual environment is activated and selected as the Python interpreter. In VS Code, click the Python version in the bottom status bar and select the interpreter inside your venv folder.
Help me fix import errors in my FastAPI project.
Run the server and test your endpoints
Time to see your API in action. FastAPI generates interactive documentation automatically, so you can test endpoints directly in the browser.
- Start the development server by running this command in your terminal:
fastapi dev main.py
What about auto-reload?
The fastapi dev command enables auto-reload by default. Whenever you save changes to main.py, the server restarts automatically. You only need to run this command once per session.
- Open your browser and navigate to http://127.0.0.1:8000/docs.
You should see the Swagger UI documentation page titled "Secure Book Collection API" with two endpoints listed.
- Click on GET /books to expand it.
- Click Try it out.
- Click Execute.
You should see a JSON response containing all three books with a 200 status code.
- Now click on GET /books/{book_id} and try it with book_id set to 1.
You should see just "The Great Gatsby" returned.
Server won't start?
- Check that your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- If you see a SyntaxError, double-check that your Pydantic class definitions use proper indentation and colons after class names.
- If port 8000 is already in use, stop any previously running server with Ctrl+C and try again.
Help me fix my FastAPI server startup error.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.py and that both endpoints return data in Swagger UI.
ⓧ I'd like to double check the full code
Here is the complete main.py at this point:
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from pydantic import BaseModel
# --- Password Hashing ---
password_hash = PasswordHash.recommended()
# --- Pydantic Models ---
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
class User(BaseModel):
username: str
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
class UserInDB(User):
hashed_password: str
class Book(BaseModel):
id: int
title: str
author: str
year: int
class BookCreate(BaseModel):
title: str
author: str
year: int
# --- Fake Database ---
fake_users_db = {
"johndoe": {
"username": "johndoe",
"full_name": "John Doe",
"email": "johndoe@example.com",
"hashed_password": password_hash.hash("secret"),
"disabled": False,
}
}
fake_books_db = [
{"id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "year": 1925},
{"id": 2, "title": "To Kill a Mockingbird", "author": "Harper Lee", "year": 1960},
{"id": 3, "title": "1984", "author": "George Orwell", "year": 1949},
]
# --- App ---
app = FastAPI(title="Secure Book Collection API")
# --- Public Endpoints ---
@app.get("/books", response_model=list[Book])
async def get_books():
return fake_books_db
@app.get("/books/{book_id}", response_model=Book)
async def get_book(book_id: int):
for book in fake_books_db:
if book["id"] == book_id:
return book
raise HTTPException(status_code=404, detail="Book not found")
Your public API is live. Anyone can browse books without needing to log in. Next up, you'll add password hashing and user authentication so you can protect the endpoints that modify data.
Implement Password Hashing and User Authentication
Your public API is up and running. Anyone can browse your book collection right now, which is great for discovery.
But what about adding, editing, or deleting books? Those operations need to be restricted to verified users. In this step, you'll build the authentication foundation: password hashing functions, a user verification system, and the cryptographic key that will sign your tokens.
In this step, get ready to:
- Create password hashing and verification helper functions.
- Build an authenticate_user function with timing-attack protection.
- Generate a secret key and configure JWT signing settings.
Set up the password hashing utilities
Before anyone can log in, your API needs a way to check passwords without storing them in plain text. You'll create helper functions that hash passwords and verify them against stored hashes.
You'll also set up OAuth2PasswordBearer, which tells FastAPI where to look for authentication tokens in incoming requests.
- In main.py, find the # --- App --- comment section (near the bottom of the file, above the public endpoints).
- Replace that entire section (the comment and the app = FastAPI(...) line) with the following code:
# --- Auth Utilities ---
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI(title="Secure Book Collection API")
def verify_password(plain_password: str, hashed_password: str) -> bool:
# Compare a plain password against its hashed version
return password_hash.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
# Generate a secure Argon2 hash from a plain password
return password_hash.hash(password)
What does this code do?
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") creates a security scheme that tells FastAPI to look for a Bearer token in the Authorization header. The tokenUrl="token" parameter points to the login endpoint you'll build in the next step.
verify_password uses pwdlib's Argon2 algorithm to check if a plain-text password matches a stored hash. get_password_hash creates a new hash from a plain password.
- Save main.py.
- You won't see a visible change yet. The next chunk uses these functions to build the full authentication flow.
Seeing a NameError for OAuth2PasswordBearer?
Make sure the import from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm is still at the top of your file. It was added in the previous step.
help me fix a NameError for OAuth2PasswordBearer in my FastAPI project
Create the authenticate_user function
When a user tries to log in, your API needs to look them up in the database and verify their password. A naive implementation has a subtle security flaw: if the username doesn't exist, the function returns immediately. An attacker can measure that faster response time to figure out which usernames are valid.
You'll fix this with a timing-attack mitigation pattern recommended by the FastAPI documentation.
- In main.py, find the # --- Password Hashing --- section near the top of the file.
- Find the line password_hash = PasswordHash.recommended() and add this line directly below it:
DUMMY_HASH = password_hash.hash("dummypassword")
Why do we need a dummy hash?
DUMMY_HASH is a pre-computed hash that gets verified when a username doesn't exist. This ensures the response takes roughly the same amount of time whether the username is valid or not. Without it, an attacker could enumerate valid usernames by measuring response times.
- Now scroll down to the # --- Auth Utilities --- section.
- Add the following get_user function below get_password_hash:
def get_user(db: dict, username: str) -> UserInDB | None:
# Look up a user in the database by username
if username in db:
user_dict = db[username]
return UserInDB(**user_dict)
return None
What does get_user do?
get_user takes the fake database dictionary and a username string. If the username exists, it unpacks the user data into a UserInDB Pydantic model (which includes the hashed password). If not found, it returns None.
- Add the authenticate_user function directly below get_user:
def authenticate_user(fake_db: dict, username: str, password: str) -> UserInDB | bool:
# Look up the user and verify their password
user = get_user(fake_db, username)
if not user:
# Timing attack mitigation: still run a hash check even if user doesn't exist
verify_password(password, DUMMY_HASH)
return False
if not verify_password(password, user.hashed_password):
return False
return user
What does authenticate_user do?
This function is the core of your login logic. It looks up the user, then verifies their password against the stored hash.
The key security detail: when get_user returns None (user doesn't exist), the function still calls verify_password against DUMMY_HASH. This makes the response time consistent regardless of whether the username is valid.
- Save main.py.
- You won't see a visible change yet. These functions will be used by the login endpoint in the next step.
Seeing a syntax error after saving?
Make sure each function has a blank line separating it from the previous one. Python requires consistent indentation inside function bodies (4 spaces per level).
help me fix a syntax error in my authenticate_user function
Generate a secret key and configure JWT settings
JWT tokens are signed with a secret key. Anyone who has this key can create valid tokens for your API, so it must be unique and unpredictable. You'll generate one using Python's built-in secrets module.
- Open a second terminal window (or press Ctrl+C in your current terminal to stop the dev server temporarily).
- Make sure your virtual environment is still active (you should see (venv) in your prompt).
- Generate a random secret key by running this command:
python -c "import secrets; print(secrets.token_hex(32))"
What does this command do?
secrets.token_hex(32) generates a cryptographically secure random string of 64 hexadecimal characters (32 bytes). This is the industry-standard way to create signing keys for JWTs.
Copy the output string. You'll paste it into your code next.
- In main.py, find the # --- Password Hashing --- comment near the top of the file (below all the imports).
- Add the following Security Configuration section above the # --- Password Hashing --- comment:
# --- Security Configuration ---
# IMPORTANT: Generate your own key with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY = "your-secret-key-here"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
What do these settings do?
SECRET_KEY is the cryptographic key used to sign and verify JWT tokens. Replace "your-secret-key-here" with the hex string you generated in the terminal.
ALGORITHM specifies HS256 (HMAC with SHA-256), the most common JWT signing algorithm. ACCESS_TOKEN_EXPIRE_MINUTES sets tokens to expire after 30 minutes, limiting the damage if a token is stolen.
- Replace "your-secret-key-here" with the hex string you copied from the terminal.
- Save main.py.
- If you stopped the dev server, restart it by running:
fastapi dev main.py
- Check your terminal output. You should see Application startup complete. with no errors.
Server won't start or shows an error?
If you see an IndentationError or SyntaxError, check that your new Security Configuration section is placed between the imports and the # --- Password Hashing --- comment, with a blank line above and below.
If you see NameError: name 'DUMMY_HASH' is not defined, make sure you added the DUMMY_HASH line directly below password_hash = PasswordHash.recommended().
help me fix the server startup error in my FastAPI project
✔️ Awesome, I've got everything!
Great. Double check you've 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 file up to this point. Compare it against yours:
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from pydantic import BaseModel
# --- Security Configuration ---
# IMPORTANT: Generate your own key with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY = "your-secret-key-here"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
# --- Password Hashing ---
password_hash = PasswordHash.recommended()
DUMMY_HASH = password_hash.hash("dummypassword")
# --- Pydantic Models ---
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
class User(BaseModel):
username: str
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
class UserInDB(User):
hashed_password: str
class Book(BaseModel):
id: int
title: str
author: str
year: int
class BookCreate(BaseModel):
title: str
author: str
year: int
# --- Fake Database ---
fake_users_db = {
"johndoe": {
"username": "johndoe",
"full_name": "John Doe",
"email": "johndoe@example.com",
"hashed_password": password_hash.hash("secret"),
"disabled": False,
}
}
fake_books_db = [
{"id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "year": 1925},
{"id": 2, "title": "To Kill a Mockingbird", "author": "Harper Lee", "year": 1960},
{"id": 3, "title": "1984", "author": "George Orwell", "year": 1949},
]
# --- Auth Utilities ---
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI(title="Secure Book Collection API")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return password_hash.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return password_hash.hash(password)
def get_user(db: dict, username: str) -> UserInDB | None:
if username in db:
user_dict = db[username]
return UserInDB(**user_dict)
return None
def authenticate_user(fake_db: dict, username: str, password: str) -> UserInDB | bool:
user = get_user(fake_db, username)
if not user:
verify_password(password, DUMMY_HASH)
return False
if not verify_password(password, user.hashed_password):
return False
return user
# --- Public Endpoints ---
@app.get("/books", response_model=list[Book])
async def get_books():
return fake_books_db
@app.get("/books/{book_id}", response_model=Book)
async def get_book(book_id: int):
for book in fake_books_db:
if book["id"] == book_id:
return book
raise HTTPException(status_code=404, detail="Book not found")
Your authentication foundation is solid. You have password hashing, user verification, and a secret key ready to sign tokens. Next up, you'll create the login endpoint that puts all of these pieces together to issue JWT access tokens.
Create the Login Endpoint and JWT Token Generation
Your password verification and user authentication logic is ready. Now you need a way for users to actually log in and receive proof that they authenticated successfully.
That proof comes in the form of a JWT (JSON Web Token). When a user logs in with valid credentials, your API creates a signed token containing their identity and an expiration time. The user then sends this token with every future request to prove who they are.
In this step, get ready to:
- Create a function that encodes user data into a signed JWT token.
- Build the login endpoint that issues tokens to authenticated users.
- Test the full login flow using Swagger UI's built-in Authorize button.
Create the token generation function
A JWT token is a compact, self-contained package of information. Your create_access_token function takes a user's identity, stamps it with an expiration time, and signs it with your secret key so nobody can tamper with it.
- In main.py, find the authenticate_user function. Add the following create_access_token function directly below it (before the # --- Public Endpoints --- comment):
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
# Copy the data so we don't modify the original
to_encode = data.copy()
# Set the expiration time
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
# Sign and return the token
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
What does this code do?
This function packages user data into a JWT token that expires after a set time.
- to_encode = data.copy() creates a copy so we can safely add the expiration claim without modifying the caller's data.
- The exp key is a standard JWT claim that tells the recipient when this token becomes invalid.
- jwt.encode() from PyJWT signs the payload using your SECRET_KEY and the HS256 algorithm. Anyone who receives this token can read its contents, but only your server can verify or create valid signatures.
- Save main.py.
You won't see a visible change yet. The next chunk builds the login endpoint that calls this function.
Build the login endpoint
The login endpoint is where everything connects. A user sends their username and password, your server verifies them, and if they check out, it returns a freshly minted JWT token.
- In main.py, scroll down past your public endpoints (get_books and get_book). Add the following code at the very bottom of the file:
# --- Auth Endpoint ---
@app.post("/token", response_model=Token)
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
# Verify username and password against the database
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create a token that expires after the configured time
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 endpoint do?
This is the login endpoint that issues JWT tokens to authenticated users.
- OAuth2PasswordRequestForm is a FastAPI dependency that automatically parses the username and password from the request body. It follows the OAuth2 standard format.
- The "sub" key (short for "subject") is a standard JWT claim that holds the unique identifier for the token's owner.
- If authentication fails, the endpoint returns a 401 Unauthorized response with a WWW-Authenticate: Bearer header. This tells the client that Bearer token authentication is required.
Why is the endpoint called /token?
Earlier in the project, you set up OAuth2PasswordBearer(tokenUrl="token"). That told FastAPI where to send login requests. This endpoint matches that URL, completing the connection between FastAPI's security system and your authentication logic.
- Save main.py.
Seeing a syntax error?
Make sure the # --- Auth Endpoint --- comment is placed after the closing line of your get_book function, with a blank line separating them. Check that the Annotated[OAuth2PasswordRequestForm, Depends()] type hint is on the form_data parameter, not the function itself.
Still stuck? Help me debug my /token endpoint
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on to testing.
ⓧ I'd like to double check the full code
Here is the complete main.py so far:
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from pydantic import BaseModel
# --- Security Configuration ---
# IMPORTANT: Generate your own key with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY = "your-secret-key-here"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
# --- Password Hashing ---
password_hash = PasswordHash.recommended()
DUMMY_HASH = password_hash.hash("dummypassword")
# --- Pydantic Models ---
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
class User(BaseModel):
username: str
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
class UserInDB(User):
hashed_password: str
class Book(BaseModel):
id: int
title: str
author: str
year: int
class BookCreate(BaseModel):
title: str
author: str
year: int
# --- Fake Database ---
fake_users_db = {
"johndoe": {
"username": "johndoe",
"full_name": "John Doe",
"email": "johndoe@example.com",
"hashed_password": password_hash.hash("secret"),
"disabled": False,
}
}
fake_books_db = [
{"id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "year": 1925},
{"id": 2, "title": "To Kill a Mockingbird", "author": "Harper Lee", "year": 1960},
{"id": 3, "title": "1984", "author": "George Orwell", "year": 1949},
]
# --- Auth Utilities ---
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI(title="Secure Book Collection API")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return password_hash.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return password_hash.hash(password)
def get_user(db: dict, username: str) -> UserInDB | None:
if username in db:
user_dict = db[username]
return UserInDB(**user_dict)
return None
def authenticate_user(fake_db: dict, username: str, password: str) -> UserInDB | bool:
user = get_user(fake_db, username)
if not user:
verify_password(password, DUMMY_HASH)
return False
if not verify_password(password, user.hashed_password):
return False
return user
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
# --- Public Endpoints ---
@app.get("/books", response_model=list[Book])
async def get_books():
return fake_books_db
@app.get("/books/{book_id}", response_model=Book)
async def get_book(book_id: int):
for book in fake_books_db:
if book["id"] == book_id:
return book
raise HTTPException(status_code=404, detail="Book not found")
# --- Auth Endpoint ---
@app.post("/token", response_model=Token)
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
if not user:
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")
Test the login flow in Swagger UI
FastAPI's built-in Swagger UI has an **Authorize** button that knows about your OAuth2 setup. You can log in directly from the docs page and see the JWT token come back.
- Make sure your dev server is running. If you stopped it, start it again by running:
fastapi dev main.py
- Open your browser and go to http://127.0.0.1:8000/docs.
- Click the green Authorize button in the top right corner of the page.
- Enter johndoe in the username field.
- Enter secret in the password field.
- Click Authorize.
You should see a confirmation that says Authorized with a checkmark. This means your API successfully verified the credentials and issued a JWT token.
- Click Close to dismiss the dialog.
What just happened behind the scenes?
When you clicked Authorize, Swagger UI sent a POST request to your /token endpoint with the username and password. Your server ran authenticate_user, verified the password hash, and returned a signed JWT.
Swagger UI now stores that token in memory. Any future requests you make from this page will automatically include the token in the Authorization: Bearer <token> header. You will use this in the next step when you add protected endpoints.
Test with the /token endpoint directly
You can also expand the POST /token section in Swagger UI, click Try it out, and submit the credentials manually. The response body shows the raw JWT string, which is a long encoded string of characters.
Seeing 401 Unauthorized?
Double check you typed johndoe (all lowercase) and secret exactly. These are case-sensitive.
If you restarted the server, the password hash in fake_users_db is regenerated on each startup. This is normal and should still work. Make sure no typo crept into the authenticate_user function.
Still stuck? Help me debug my login flow
Your login system is fully functional. Users can authenticate and receive a JWT token. Next up, you will use that token to protect your book creation, update, and delete endpoints so only logged-in users can modify the collection.
Protect API Endpoints with JWT Verification
Your login endpoint is issuing JWT tokens. Now you need the other half of the equation: code that checks those tokens on incoming requests and rejects anyone who is not authenticated.
In this step, you will create dependency functions that decode and validate JWTs, then wire them into protected endpoints for creating, updating, and deleting books. Only requests carrying a valid token will get through.
In this step, get ready to:
- Create a dependency that decodes JWTs and identifies the current user.
- Add protected POST, PUT, and DELETE endpoints for books.
- Test the full authentication flow in Swagger UI.
Create the get_current_user dependency function
Every protected endpoint needs a way to verify the caller's identity. In FastAPI, this is done with a dependency injection function. The framework calls this function automatically before each request reaches the endpoint.
- In main.py, scroll down to the end of the create_access_token function (just above the # --- Public Endpoints --- comment).
- Add the get_current_user function below create_access_token by copying this code:
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:
# Decode the JWT and extract the username from the "sub" claim
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except InvalidTokenError:
# Any JWT error (expired, tampered, malformed) triggers a 401
raise credentials_exception
# Look up the user in the database
user = get_user(fake_users_db, username=token_data.username)
if user is None:
raise credentials_exception
return user
What does this code do?
This function acts as a gatekeeper for any endpoint that uses it. FastAPI automatically extracts the Bearer token from the Authorization header and passes it to this function.
- jwt.decode() verifies the token's signature and checks that it has not expired. If anything is wrong, it raises InvalidTokenError.
- The "sub" claim contains the username that was embedded when the token was created in the /token endpoint.
- If any validation step fails, the function raises a 401 Unauthorized error. The request never reaches the endpoint.
Why define credentials_exception upfront?
The same HTTPException is raised in three different places (missing username, invalid token, user not found). Defining it once keeps the code clean and ensures a consistent error response.
If you see a NameError about credentials_exception, make sure the variable assignment is inside the function, not outside it.
help me debug my get_current_user function
Now add a second dependency that checks whether the user account is active. Place this directly below get_current_user:
- Add the get_current_active_user function by copying this code:
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
# Check if the user account is disabled
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
What does this code do?
This function chains onto get_current_user. FastAPI resolves dependencies in order: first it calls get_current_user to validate the token, then passes the result to get_current_active_user which checks the disabled flag.
This two-layer pattern lets you add account-level checks (disabled, banned, email-verified) without modifying the token validation logic.
- Save main.py.
You will not see a visible change yet. The next substep uses these dependencies in actual endpoints.
Add protected POST, PUT, and DELETE endpoints for books
With the dependency functions ready, you can now create endpoints that only authenticated users can access. Each endpoint declares get_current_active_user as a dependency, which triggers the full token validation chain before the endpoint body runs.
- In main.py, scroll to the very bottom of the file (after the login_for_access_token function).
- Add the protected endpoints section by copying this code:
# --- Protected Endpoints ---
@app.post("/books", response_model=Book, status_code=201)
async def create_book(
book: BookCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
):
# Generate a new ID and add the book to the database
new_id = max(b["id"] for b in fake_books_db) + 1 if fake_books_db else 1
new_book = {"id": new_id, "title": book.title, "author": book.author, "year": book.year}
fake_books_db.append(new_book)
return new_book
@app.put("/books/{book_id}", response_model=Book)
async def update_book(
book_id: int,
book: BookCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
):
# Find and replace the book with matching ID
for i, existing_book in enumerate(fake_books_db):
if existing_book["id"] == book_id:
fake_books_db[i] = {"id": book_id, "title": book.title, "author": book.author, "year": book.year}
return fake_books_db[i]
raise HTTPException(status_code=404, detail="Book not found")
@app.delete("/books/{book_id}", status_code=204)
async def delete_book(
book_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
):
# Find and remove the book with matching ID
for i, existing_book in enumerate(fake_books_db):
if existing_book["id"] == book_id:
fake_books_db.pop(i)
return
raise HTTPException(status_code=404, detail="Book not found")
What does this code do?
These three endpoints handle creating, updating, and deleting books. The key line in each is current_user: Annotated[User, Depends(get_current_active_user)]. This tells FastAPI to run the full authentication chain before the endpoint body executes.
- POST /books creates a new book and returns it with status 201.
- PUT /books/{book_id} replaces an existing book's data.
- DELETE /books/{book_id} removes a book and returns status 204 (no content).
- If a request arrives without a valid token, FastAPI returns a 401 response before the endpoint body ever runs.
- Save main.py.
- If your dev server is still running, it auto-reloads. If you stopped it, restart it by running:
fastapi dev main.py
You should see the server reload with no errors in the terminal output.
Seeing a syntax error on startup?
Check that get_current_user and get_current_active_user are placed between the create_access_token function and the # --- Public Endpoints --- comment. Order matters because Python reads top to bottom.
Also verify that every async def line has a matching colon at the end and the body is indented with 4 spaces.
help me fix a syntax error in my FastAPI main.py file
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and the server restarted without errors.
ⓧ I'd like to double check the full code
Here is the complete main.py at this point. Compare yours against it:
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from pydantic import BaseModel
# --- Security Configuration ---
# IMPORTANT: Generate your own key with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY = "your-secret-key-here"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
# --- Password Hashing ---
password_hash = PasswordHash.recommended()
DUMMY_HASH = password_hash.hash("dummypassword")
# --- Pydantic Models ---
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
class User(BaseModel):
username: str
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
class UserInDB(User):
hashed_password: str
class Book(BaseModel):
id: int
title: str
author: str
year: int
class BookCreate(BaseModel):
title: str
author: str
year: int
# --- Fake Database ---
fake_users_db = {
"johndoe": {
"username": "johndoe",
"full_name": "John Doe",
"email": "johndoe@example.com",
"hashed_password": password_hash.hash("secret"),
"disabled": False,
}
}
fake_books_db = [
{"id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "year": 1925},
{"id": 2, "title": "To Kill a Mockingbird", "author": "Harper Lee", "year": 1960},
{"id": 3, "title": "1984", "author": "George Orwell", "year": 1949},
]
# --- Auth Utilities ---
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI(title="Secure Book Collection API")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return password_hash.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return password_hash.hash(password)
def get_user(db: dict, username: str) -> UserInDB | None:
if username in db:
user_dict = db[username]
return UserInDB(**user_dict)
return None
def authenticate_user(fake_db: dict, username: str, password: str) -> UserInDB | bool:
user = get_user(fake_db, username)
if not user:
verify_password(password, DUMMY_HASH)
return False
if not verify_password(password, user.hashed_password):
return False
return user
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
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 = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except InvalidTokenError:
raise credentials_exception
user = get_user(fake_users_db, username=token_data.username)
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
# --- Public Endpoints ---
@app.get("/books", response_model=list[Book])
async def get_books():
return fake_books_db
@app.get("/books/{book_id}", response_model=Book)
async def get_book(book_id: int):
for book in fake_books_db:
if book["id"] == book_id:
return book
raise HTTPException(status_code=404, detail="Book not found")
# --- Auth Endpoint ---
@app.post("/token", response_model=Token)
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
if not user:
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 Endpoints ---
@app.post("/books", response_model=Book, status_code=201)
async def create_book(
book: BookCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
):
new_id = max(b["id"] for b in fake_books_db) + 1 if fake_books_db else 1
new_book = {"id": new_id, "title": book.title, "author": book.author, "year": book.year}
fake_books_db.append(new_book)
return new_book
@app.put("/books/{book_id}", response_model=Book)
async def update_book(
book_id: int,
book: BookCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
):
for i, existing_book in enumerate(fake_books_db):
if existing_book["id"] == book_id:
fake_books_db[i] = {"id": book_id, "title": book.title, "author": book.author, "year": book.year}
return fake_books_db[i]
raise HTTPException(status_code=404, detail="Book not found")
@app.delete("/books/{book_id}", status_code=204)
async def delete_book(
book_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
):
for i, existing_book in enumerate(fake_books_db):
if existing_book["id"] == book_id:
fake_books_db.pop(i)
return
raise HTTPException(status_code=404, detail="Book not found")
Test the full authentication flow
The real test of your security layer is proving that unauthenticated requests are rejected and authenticated ones succeed. You will verify both scenarios in Swagger UI.
- Open your browser to http://127.0.0.1:8000/docs.
- Scroll down to the POST /books endpoint and click **Try it out**.
- Enter a request body like this:
{
"title": "Dune",
"author": "Frank Herbert",
"year": 1965
}
- Click **Execute**.
- You should see a 401 response with the detail Not authenticated. This confirms the endpoint is protected.
Now authenticate and try again:
- Scroll to the top of the page and click the **Authorize** button (the lock icon).
- Enter johndoe in the **username** field.
- Enter secret in the **password** field.
- Click **Authorize**, then **Close**.
- Scroll back to POST /books and click **Execute** again with the same body.
- You should now see a 201 response with the new book data including "id": 4.
What just happened?
When you clicked **Authorize**, Swagger UI sent your credentials to the /token endpoint and received a JWT. It then automatically attaches that token as an Authorization: Bearer <token> header on every subsequent request.
The first attempt failed because there was no token. The second succeeded because get_current_user decoded the token, found johndoe in the sub claim, and returned the user object.
- Test the DELETE endpoint: expand DELETE /books/{book_id}, click **Try it out**, enter 3 for the book_id, and click **Execute**.
- You should see a 204 response confirming the book was deleted.
Still seeing 401 after authorizing?
Make sure you entered the password secret (all lowercase). The username is johndoe (also all lowercase).
If the **Authorize** dialog showed an error, your /token endpoint may have a problem. Check the terminal for error messages. A common issue is a missing python-multipart package, which is required for form data parsing.
help me troubleshoot 401 errors after authorizing in Swagger UI
Your API is now fully secured. Public endpoints remain open for browsing, while write operations require a valid JWT. Next up, the Secret Mission challenges you to add role-based access control so only admins can delete books.
Secret mission
Add Role-Based Access Control (RBAC)
Your API authenticates users, but every logged-in user has the same power. What if you want only admins to delete books? In this secret mission, you'll add role-based access control so dangerous operations are locked to privileged users.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- FastAPI dev server (running process in your terminal).
- Project folder with main.py and the venv virtual environment (includes all installed packages).
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your API.
- Your FastAPI dev server continues running in the terminal.
- Your virtual environment stays activated.
- You can keep testing endpoints at http://127.0.0.1:8000/docs.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files stay on disk so you can resume later.
- In the terminal where your FastAPI server is running, press Ctrl+C to stop the server.
- Deactivate the virtual environment by running:
deactivate
When you come back, reactivate the environment and restart the server.
- On macOS/Linux, run source venv/bin/activate then fastapi dev main.py.
- On Windows, run venv\Scripts\activate then fastapi dev main.py.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your code and the virtual environment permanently.
- In the terminal where your FastAPI server is running, press Ctrl+C to stop the server.
- Deactivate the virtual environment by running:
deactivate
- Navigate to your Desktop and delete the project folder.
On macOS/Linux:
cd ~/Desktop
rm -rf secure-book-api
On Windows:
cd ~\Desktop
rmdir /s /q secure-book-api
This removes main.py, the venv folder, and all installed packages in one step.
Nice Work!
Nice Work!
You've built a secure REST API from scratch using FastAPI and industry-standard authentication. Your API protects sensitive operations behind JWT tokens and stores passwords safely using the Argon2 hashing algorithm.
You've learned how to:
- Built a FastAPI REST API with public and protected endpoints using dependency injection to enforce authentication.
- Implemented JWT authentication with the OAuth2 password flow, issuing and verifying stateless access tokens.
- Securely hashed passwords using the Argon2 algorithm via pwdlib, with timing-attack mitigation built in.
- Secret Mission: Added Role-Based Access Control to restrict the DELETE endpoint to admin users only.
Ready to quiz yourself?