Secure a Python REST API with JWT
Build a FastAPI bookmarks app secured with JWT auth, Argon2 hashing, and rate limiting.
Introduction
30 Second Summary
Every time you log into an app, there's an API behind the scenes checking your identity and deciding what you're allowed to see. Without proper security, anyone could access private data or impersonate other users.
In this project, you will build a Bookmarks API using FastAPI and secure it step by step with password hashing, JWT token authentication, rate limiting, and CORS.
What You'll Build
A REST API where users register, log in, and save personal bookmarks, protected by industry-standard security features you configure from scratch.
By the end of this project, you'll have:
- A fully working API with JWT-based authentication that issues and validates bearer tokens to protect private routes.
- Password hashing with Argon2 ensuring stored credentials are never exposed in plaintext.
- Rate limiting and CORS middleware configured to prevent API abuse and unauthorized cross-origin access.
- In the Secret Mission, you'll add role-based access control (RBAC) so admin users can delete any bookmark while regular users can only manage their own.
Are there any prerequisites?
You'll need basic Python knowledge (variables, functions, dictionaries). No prior API or web development experience is required.
Set Up Your Python Environment
Before you can build any security features, you need a clean Python environment with the right packages installed.
In this step, you'll create a project folder, set up an isolated virtual environment, and install the four libraries that power the Bookmarks API. Then you'll launch a minimal FastAPI server to confirm everything works.
In this step, get ready to:
- Install Python and create a virtual environment for the project.
- Install FastAPI, PyJWT, pwdlib, and slowapi.
- Create the project files and run a Hello World API.
Install Python and create your project directory
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
This project requires Python 3.10 or higher. Check which version you have by running the command for your operating system.
- Check your Python version by running:
macOS
python3 --version
Windows
python --version
I see a version number
You should see something like Python 3.12.x. As long as the number is 3.10 or higher, you're good to go.
Command not found
- Go to python.org/downloads in your browser.
- Click the Download Python button to download the latest version.
- Run the installer.
- On Windows, make sure you check the Add python.exe to PATH checkbox before clicking Install Now.
- Close and reopen your terminal, then run the version check command again to confirm the install worked.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir secure-bookmarks-api
cd secure-bookmarks-api
A virtual environment keeps this project's packages separate from the rest of your system. This way, nothing you install here can break other Python projects on your machine.
- Create and activate a virtual environment by running:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
.\venv\Scripts\Activate.ps1
Seeing a script execution error?
PowerShell may block scripts by default. Run this command to allow them for your account, then try the activation command again:
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
You'll know the virtual environment is active when you see (venv) at the beginning of your terminal prompt.
Don't see (venv) in your prompt?
Make sure you ran both the create and activate commands. The first command (python3 -m venv venv) creates the environment. The second command (source or Activate.ps1) turns it on.
If you closed and reopened your terminal, you'll need to cd back into secure-bookmarks-api and run the activate command again.
Help me activate my virtual environment.
Install all project dependencies
With your virtual environment active, you can now install the four packages this project needs.
- Install all project dependencies by running:
pip install "fastapi[standard]" pyjwt "pwdlib[argon2]" slowapi
This command downloads and installs all four packages plus their sub-dependencies. It may take a minute or two.
What does each package do?
- fastapi[standard] is the web framework you'll use to build the API. The [standard] extra bundles the development server and other essentials.
- pyjwt creates and verifies JWT tokens, the industry-standard way to authenticate API requests.
- pwdlib[argon2] hashes passwords using Argon2, the current recommended hashing algorithm.
- slowapi adds rate limiting to prevent people from flooding your API with requests.
- Verify the packages installed by running:
pip list
You should see fastapi, PyJWT, pwdlib, and slowapi in the output list, along with their sub-dependencies.
Seeing install errors?
- Make sure (venv) is still visible in your terminal prompt. If it disappeared, re-activate the virtual environment before running pip install again.
- If you see a Rust compiler or cargo error, the Argon2 C extension failed to build. Update pip first with pip install --upgrade pip and try again.
Help me fix my pip install errors.
Create the project files and verify the setup
The project uses three files to keep concerns separated: main.py for routes, auth.py for authentication logic, and models.py for data shapes.
- Create the three project files by running:
macOS
touch main.py auth.py models.py
Windows
New-Item main.py, auth.py, models.py -ItemType File
- Confirm the files exist by running:
ls
You should see auth.py, main.py, models.py, and the venv folder in the output.
Now you'll add a simple Hello World route to main.py to confirm FastAPI is working.
- Open main.py in your text editor and add the following code:
from fastapi import FastAPI
# Create the FastAPI application instance
app = FastAPI()
# A simple test route to confirm the server is running
@app.get("/")
def read_root():
return {"message": "Hello World"}
What does this code do?
- from fastapi import FastAPI imports the FastAPI class from the library you just installed.
- app = FastAPI() creates an application instance. All your routes will attach to this object.
- @app.get("/") is a decorator that tells FastAPI to call read_root() whenever someone sends a GET request to the root path.
- The function returns a Python dictionary. FastAPI automatically converts it to JSON in the response.
- Save main.py.
- Start the FastAPI development server by running:
fastapi dev main.py
What should I see in the terminal?
The terminal displays a startup log with several lines. Look for the line that says Uvicorn running on http://127.0.0.1:8000. That confirms the server started successfully.
The terminal will stay active while the server runs. Keep it open.
- Open your browser and go to http://127.0.0.1:8000.
You should see the JSON response {"message": "Hello World"} displayed in the browser.
- Now go to http://127.0.0.1:8000/docs.
This is the Swagger UI, an interactive documentation page that FastAPI generates automatically. You'll use this throughout the project to test every endpoint directly from your browser.
Page not loading in the browser?
- Check your terminal for error messages. A SyntaxError means there's a typo in main.py.
- Make sure the URL starts with http://, not https://. The dev server does not use HTTPS.
- If you see Address already in use, another process is using port 8000. Close other terminals or restart your computer.
Help me get my FastAPI server running.
Awesome, I've got everything!
Double check you've saved main.py and your server is running.
I'd like to double check the full code
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello World"}
Your environment is ready and FastAPI is running. Next up, you'll build the actual Bookmarks API with endpoints for creating and listing bookmarks.
Build an Unprotected Bookmarks API
Your environment is set up and your server is running. Now it's time to build something real.
In this step, you'll create a Bookmarks API where users can save and retrieve their favorite web links. The API starts completely open on purpose. Seeing how exposed an unprotected API is makes the security layers you add later feel much more meaningful.
In this step, get ready to:
- Define Pydantic models for bookmarks and users.
- Create POST and GET endpoints for bookmarks.
- Test the unprotected API in Swagger UI.
Define Pydantic models for bookmarks
Pydantic models define the exact shape of data your API accepts and returns. If someone sends a request missing a required field or using the wrong type, FastAPI automatically rejects it with a clear error message.
- Open models.py in your editor and add the bookmark models by pasting this code:
from pydantic import BaseModel
class BookmarkCreate(BaseModel):
url: str
title: str
class BookmarkResponse(BaseModel):
id: int
url: str
title: str
owner: str
What does this code do?
- BookmarkCreate defines what the API expects when someone creates a bookmark: a url and a title.
- BookmarkResponse defines what the API sends back: the same fields plus an id and an owner.
- Extending BaseModel gives each class automatic validation. FastAPI rejects any request that doesn't match these type annotations.
You won't see a change yet. These models are used by the endpoints you'll write next.
- Now add the user and authentication models below the bookmark models in the same file:
class UserCreate(BaseModel):
username: str
password: str
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
disabled: bool | None = None
class UserInDB(User):
hashed_password: str
What does this code do?
- UserCreate is the shape of a registration request: just a username and password.
- Token and TokenData define the structure of JWT tokens you'll issue in a later step.
- User represents a user's public profile. UserInDB extends it with a hashed_password field for secure storage.
- Save models.py.
You won't see a visible change yet. The next substep uses these models in your API endpoints.
Seeing a red underline on str | None?
The str | None syntax requires Python 3.10 or newer. Confirm your version by running python --version in your terminal.
Help me fix a Python version issue with type annotations.
Awesome, I've got everything!
Great. Double check you've saved models.py before moving on.
I'd like to double check the full code
from pydantic import BaseModel
class BookmarkCreate(BaseModel):
url: str
title: str
class BookmarkResponse(BaseModel):
id: int
url: str
title: str
owner: str
class UserCreate(BaseModel):
username: str
password: str
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
disabled: bool | None = None
class UserInDB(User):
hashed_password: str
Create bookmark endpoints in main.py
With the models ready, you can build the actual API endpoints. You'll replace the Hello World app with real CRUD operations for bookmarks.
- Replace the entire contents of main.py with the following imports and app setup:
from fastapi import FastAPI
from models import BookmarkCreate, BookmarkResponse
app = FastAPI(title="Secure Bookmarks API")
# In-memory bookmark storage
bookmarks: list[dict] = []
bookmark_id_counter = 0
What does this code do?
- The import from models pulls in the Pydantic classes you just defined. FastAPI uses them to validate requests and format responses.
- bookmarks is a simple Python list acting as your data store. In a production app this would be a database, but a list keeps things focused on the security concepts.
- bookmark_id_counter gives each new bookmark a unique ID by incrementing with every creation.
You won't see a change yet. The next chunk adds the endpoints that actually handle requests.
- Add the POST endpoint below the bookmark_id_counter line by pasting this code:
@app.post("/bookmarks", response_model=BookmarkResponse)
async def create_bookmark(bookmark: BookmarkCreate):
global bookmark_id_counter
bookmark_id_counter += 1
new_bookmark = {
"id": bookmark_id_counter,
"url": bookmark.url,
"title": bookmark.title,
"owner": "anonymous",
}
bookmarks.append(new_bookmark)
return new_bookmark
What does this code do?
- @app.post("/bookmarks") registers this function to handle POST requests at the /bookmarks path.
- response_model=BookmarkResponse tells FastAPI to format the return value using your Pydantic model. This also generates documentation in Swagger.
- The owner is hardcoded to "anonymous" because there is no authentication yet. Anyone can create a bookmark with no identity check.
- Save main.py.
- Check your terminal where fastapi dev main.py is running. You should see it auto-reload with no errors.
Seeing an ImportError?
If you see ImportError: cannot import name 'BookmarkCreate' from 'models', make sure you saved models.py with the class definitions from the previous substep.
Also confirm that models.py and main.py are in the same folder.
Help me fix an ImportError in my FastAPI project.
- Add the GET endpoint below the create_bookmark function by pasting this code:
@app.get("/bookmarks", response_model=list[BookmarkResponse])
async def get_bookmarks():
return bookmarks
What does this code do?
This endpoint returns every bookmark in the list. There is no filtering by user because there is no concept of "who is asking" yet. Anyone who calls this endpoint sees everything.
- Save main.py.
- Check your terminal. The server should auto-reload with no errors and show both /bookmarks routes available.
Server not reloading?
If the server crashed after your earlier save, restart it by running fastapi dev main.py again in your terminal. Make sure you're running it from the same folder that contains main.py.
Help me get my FastAPI dev server running again.
Awesome, I've got everything!
Great. Double check you've saved main.py and the server is running without errors.
I'd like to double check the full code
from fastapi import FastAPI
from models import BookmarkCreate, BookmarkResponse
app = FastAPI(title="Secure Bookmarks API")
# In-memory bookmark storage
bookmarks: list[dict] = []
bookmark_id_counter = 0
@app.post("/bookmarks", response_model=BookmarkResponse)
async def create_bookmark(bookmark: BookmarkCreate):
global bookmark_id_counter
bookmark_id_counter += 1
new_bookmark = {
"id": bookmark_id_counter,
"url": bookmark.url,
"title": bookmark.title,
"owner": "anonymous",
}
bookmarks.append(new_bookmark)
return new_bookmark
@app.get("/bookmarks", response_model=list[BookmarkResponse])
async def get_bookmarks():
return bookmarks
Test the API in Swagger UI
FastAPI automatically generates interactive documentation for your API. You can test every endpoint directly in your browser without any extra tools.
- Open your browser and navigate to http://127.0.0.1:8000/docs.
You should see the Swagger UI with two endpoints listed: POST /bookmarks and GET /bookmarks.
- Click on the POST /bookmarks row to expand it.
- Click Try it out in the top right of the expanded section.
- Replace the request body with this JSON:
{
"url": "https://nextwork.org",
"title": "NextWork"
}
- Click Execute.
You should see a 200 response with your bookmark data, including an id of 1 and an owner of "anonymous".
- Create one or two more bookmarks with different URLs and titles by clicking Execute again after changing the request body.
- Now expand the GET /bookmarks row.
- Click Try it out, then Execute.
You should see all the bookmarks you created returned in a JSON list.
Notice the security problem?
There was no login required. No password. No token. Anyone with the URL can create bookmarks and see every bookmark in the system. Every owner is just "anonymous".
This is exactly the problem you'll solve in the next steps by adding password hashing and JWT authentication.
Your bookmarks API is live and functional. Next up, you'll add password hashing and user registration so that each bookmark actually belongs to a real, authenticated user.
Add Password Hashing and User Registration
Your Bookmarks API is live and you can create and retrieve bookmarks through Swagger UI. There is one glaring problem: anyone can access everything without logging in.
In this step, you will add user registration with secure password storage. Instead of saving passwords as plain text (a catastrophic security mistake), you will hash them using Argon2. Even if an attacker steals your entire user database, the passwords stay protected.
In this step, get ready to:
- Set up password hashing utilities in auth.py.
- Add a user registration endpoint to main.py.
- Verify passwords are stored as Argon2 hashes, not plaintext.
Set up password hashing in auth.py
Password hashing is a one-way transformation. You can turn a password into a hash, but you can never reverse a hash back into a password.
The pwdlib library gives you Argon2, the winner of the Password Hashing Competition and the current recommended algorithm for hashing passwords.
- Open auth.py in your editor. This file is currently empty.
- Add the password hashing configuration by copying this code into auth.py:
from pwdlib import PasswordHash
from models import UserInDB
# Create a password hasher using Argon2 (current best practice)
password_hash = PasswordHash.recommended()
# Pre-computed hash for timing-attack protection (used in login later)
DUMMY_HASH = password_hash.hash("dummypassword")
# In-memory user storage
users_db: dict[str, dict] = {}
What does this code do?
- PasswordHash.recommended() creates a hasher that defaults to Argon2. You do not need to configure any parameters.
- DUMMY_HASH is a pre-computed hash used to prevent timing attacks. You will use this in the login step to ensure the server takes the same amount of time whether a username exists or not.
- users_db is a dictionary that stores registered users in memory. In a production app this would be a real database.
- Save auth.py.
You won't see a change yet. The utility functions you add next will use this setup.
- Below the users_db line, add these utility functions:
def verify_password(plain_password: str, hashed_password: str) -> bool:
# Check if a plain password matches a hashed password
return password_hash.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
# Turn a plain password into an Argon2 hash
return password_hash.hash(password)
def get_user(username: str) -> UserInDB | None:
# Look up a user by username and return a UserInDB model
if username in users_db:
user_dict = users_db[username]
return UserInDB(**user_dict)
return None
What do these functions do?
- verify_password() takes a plain text password and a hash, then returns True if they match. The hashing library handles the comparison securely.
- get_password_hash() converts a plain text password into an Argon2 hash for safe storage.
- get_user() looks up a username in the database and returns a UserInDB model. You will use this in the login step to authenticate users.
- Save auth.py.
You won't see a visible change yet. The register endpoint you build next will call get_password_hash() to hash passwords before storing them.
Awesome, I've got everything!
Double check you have saved auth.py before moving on.
I'd like to double check the full code
from pwdlib import PasswordHash
from models import UserInDB
password_hash = PasswordHash.recommended()
DUMMY_HASH = password_hash.hash("dummypassword")
# In-memory user storage
users_db: dict[str, dict] = {}
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(username: str) -> UserInDB | None:
if username in users_db:
user_dict = users_db[username]
return UserInDB(**user_dict)
return None
Add the registration endpoint
With the hashing utilities ready, you can now build a registration endpoint that accepts a username and password, hashes the password, and stores the user.
- In main.py, find the import lines at the top of the file:
from fastapi import FastAPI
from models import BookmarkCreate, BookmarkResponse
- Replace those lines with the following updated imports:
from fastapi import FastAPI, HTTPException, status
from auth import get_password_hash, users_db
from models import BookmarkCreate, BookmarkResponse, UserCreate
What changed in the imports?
- HTTPException and status let you return proper error codes (like 400 Bad Request) when something goes wrong.
- get_password_hash and users_db come from the auth.py module you just built.
- UserCreate is the Pydantic model that validates incoming registration data (username and password).
- Now add the registration endpoint. In main.py, find the line that says bookmark_id_counter = 0, then copy the following code below it:
@app.post("/register")
async def register(user_data: UserCreate):
# Check if username is already taken
if user_data.username in users_db:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
# Hash the password before storing it
hashed_password = get_password_hash(user_data.password)
users_db[user_data.username] = {
"username": user_data.username,
"email": None,
"disabled": False,
"hashed_password": hashed_password,
}
return {"username": user_data.username, "message": "User created successfully"}
What does this endpoint do?
- FastAPI automatically validates the request body against the UserCreate model. If a username or password is missing, the client gets a 422 error before your code even runs.
- The endpoint first checks if the username already exists. If it does, it raises a 400 Bad Request error.
- get_password_hash() converts the plain text password into an Argon2 hash before storing it. The original password is never saved.
- Save main.py.
- If your dev server is still running, it should automatically reload without errors. If you stopped it, start it again by running:
fastapi dev main.py
Seeing an ImportError?
- Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt.
- Check that auth.py is in the same folder as main.py. Python cannot find the module if they are in different directories.
- Verify the function names in your import match exactly: get_password_hash and users_db.
Help me fix my ImportError in main.py
Awesome, I've got everything!
Double check you have saved main.py before moving on.
I'd like to double check the full code
from fastapi import FastAPI, HTTPException, status
from auth import get_password_hash, users_db
from models import BookmarkCreate, BookmarkResponse, UserCreate
app = FastAPI(title="Secure Bookmarks API")
# In-memory bookmark storage
bookmarks: list[dict] = []
bookmark_id_counter = 0
@app.post("/register")
async def register(user_data: UserCreate):
if user_data.username in users_db:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
hashed_password = get_password_hash(user_data.password)
users_db[user_data.username] = {
"username": user_data.username,
"email": None,
"disabled": False,
"hashed_password": hashed_password,
}
return {"username": user_data.username, "message": "User created successfully"}
@app.post("/bookmarks", response_model=BookmarkResponse)
async def create_bookmark(bookmark: BookmarkCreate):
global bookmark_id_counter
bookmark_id_counter += 1
new_bookmark = {
"id": bookmark_id_counter,
"url": bookmark.url,
"title": bookmark.title,
"owner": "anonymous",
}
bookmarks.append(new_bookmark)
return new_bookmark
@app.get("/bookmarks", response_model=list[BookmarkResponse])
async def get_bookmarks():
return bookmarks
Test registration and verify password hashing
Time to see password hashing in action. You will register a user through Swagger UI, then inspect the stored data to confirm the password was hashed.
- Open your browser and go to http://127.0.0.1:8000/docs.
- Find the POST /register endpoint and click it to expand it.
- Click Try it out.
- Replace the request body with this JSON:
{
"username": "alice",
"password": "secretpass123"
}
- Click Execute.
You should see a 200 response with {"username": "alice", "message": "User created successfully"}.
The response only shows the username, not the stored password. To confirm the password was actually hashed, you will add a temporary debug endpoint.
- In main.py, add this temporary endpoint at the bottom of the file:
# TEMPORARY: Remove this endpoint after testing
@app.get("/debug/users")
async def debug_users():
return users_db
- Save main.py and refresh the Swagger UI page.
- If you already registered a user, the server reloaded and cleared the in-memory data. Register the alice user again using the same steps above.
- Find the GET /debug/users endpoint, click Try it out, then click Execute.
In the response body, look at the hashed_password field. It should start with $argon2id$ followed by a long string of characters. That is your Argon2 hash.
Why does this matter?
The plain text password secretpass123 is nowhere in the stored data. If an attacker accessed this database, they would see the Argon2 hash and have no way to reverse it back into the original password.
- Now remove the temporary debug endpoint from main.py. Delete the entire @app.get("/debug/users") function and its comment.
- Save main.py.
Pro tip: debug endpoints
Temporary debug endpoints are handy for inspecting internal state during development. Always remove them before deploying to production. Exposing user data through a public endpoint is exactly the kind of vulnerability you are learning to prevent.
Don't see the Argon2 hash?
- If the response is empty ({}), the server reloaded and cleared the in-memory data. Register the user again first, then call the debug endpoint.
- If the hashed_password field shows the plain text password instead of a hash, check that your register function calls get_password_hash(user_data.password) before storing the user.
Help me debug why my password is not being hashed
Your users can now register with securely hashed passwords. Next up, you will build the login system that issues JWT tokens so users can prove who they are on every request.
Implement JWT Authentication
In the previous step, you hashed user passwords so they are never stored in plaintext. But the bookmark endpoints are still wide open.
JWT (JSON Web Token) authentication fixes this. When a user logs in, the server issues a signed token. The client sends that token with every future request, and the server verifies it instead of re-checking the password each time.
In this step, get ready to:
- Configure JWT settings and create token functions in auth.py.
- Add a login endpoint that returns JWT access tokens.
- Protect bookmark endpoints so only authenticated users can access them.
Configure JWT settings and create auth functions
JWT tokens are signed with a secret key that only your server knows. Anyone who has this key can forge tokens, so it needs to be random and kept private.
- Generate a random secret key by running this command in your terminal:
openssl rand -hex 32
- Copy the output string. You will paste it into your code in a moment.
Command not found?
If openssl is not recognized, you can use Python instead. Run python -c "import secrets; print(secrets.token_hex(32))" to generate the same kind of random string.
Now you need to expand auth.py with the JWT configuration, new imports, and an OAuth2 scheme that tells FastAPI where to find tokens.
- In auth.py, replace everything from the first line through the DUMMY_HASH line with the following code. Then add the oauth2_scheme line right after:
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from models import TokenData, User, UserInDB
# Generate your own key with: openssl rand -hex 32
SECRET_KEY = "your-secret-key-here"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
password_hash = PasswordHash.recommended()
DUMMY_HASH = password_hash.hash("dummypassword")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
What does this code do?
- SECRET_KEY is the private key used to sign and verify every JWT token. Replace "your-secret-key-here" with the random string you just generated.
- ALGORITHM = "HS256" sets the signing algorithm. HS256 is a widely used HMAC-based algorithm for JWT.
- ACCESS_TOKEN_EXPIRE_MINUTES = 30 means tokens expire after 30 minutes. Short-lived tokens limit the damage if one is stolen.
- oauth2_scheme tells FastAPI to look for a bearer token in the Authorization header. It also powers the Authorize button in Swagger UI.
- Replace "your-secret-key-here" with the random string you generated earlier.
- Make sure the existing # In-memory user storage comment and everything below it stays in place.
Not sure what to replace?
Your original auth.py started with from pwdlib import PasswordHash. Select from that first line down through DUMMY_HASH = password_hash.hash("dummypassword") and paste the new code in its place. The password_hash and DUMMY_HASH lines are included in the new code block, so they stay.
Help me update the imports and add JWT config to auth.py.
Next, you need a function that checks whether a username and password combination is valid. This function also needs to protect against timing attacks.
What is a timing attack?
If a login request for a nonexistent user returns faster than one for a real user, an attacker can measure the difference to figure out which usernames exist. The DUMMY_HASH pattern ensures the server always runs the same hashing work, regardless of whether the username is valid.
- Below the get_user() function in auth.py, add the authenticate_user() function:
def authenticate_user(username: str, password: str) -> UserInDB | bool:
user = get_user(username)
# If user doesn't exist, still run the hash to prevent timing attacks
if not user:
verify_password(password, DUMMY_HASH)
return False
# If password doesn't match, return False
if not verify_password(password, user.hashed_password):
return False
return user
What does this code do?
- get_user() looks up the username in the in-memory database.
- If the user does not exist, the function still calls verify_password() against DUMMY_HASH. This is the timing-attack protection.
- If the user exists but the password is wrong, it returns False. If everything checks out, it returns the full user object.
You won't see a visible change yet. The next function uses authenticate_user() to issue tokens.
Now you need a function that creates the JWT token itself. This function encodes a payload (containing the username and expiration time) and signs it with your secret key.
- Below authenticate_user() in auth.py, add the create_access_token() function:
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
# Set expiration time based on provided delta or default to 15 minutes
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 the token with the secret key
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
What does this code do?
- data.copy() creates a copy of the payload so the original dictionary is not modified.
- The exp claim is a standard JWT field. After this timestamp, the token is automatically rejected.
- jwt.encode() from PyJWT produces the final signed token string. Only your server's SECRET_KEY can verify it.
- Save auth.py.
You won't see a change in the running server yet. These functions are used by the login endpoint and dependency functions you will add next.
Add the login endpoint and JWT validation dependencies
FastAPI uses dependency injection to protect routes. You create a function that extracts and validates the JWT from the request header. Then you "inject" it into any endpoint that needs authentication.
What is dependency injection?
Dependency injection is a pattern where a function declares what it needs, and the framework provides it automatically. In FastAPI, adding Depends(get_current_active_user) to an endpoint's parameters tells FastAPI to run your auth check before the endpoint runs. If the check fails, the endpoint never executes.
- Below create_access_token() in auth.py, add these two dependency functions:
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
) -> UserInDB:
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:
raise credentials_exception
# Look up the user in the database
user = get_user(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)],
) -> UserInDB:
# Reject disabled user accounts
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
What does this code do?
- get_current_user() receives the raw token from the Authorization: Bearer header (via oauth2_scheme), decodes it with jwt.decode(), and looks up the user.
- The sub claim is a standard JWT field that identifies the token's subject (the username).
- get_current_active_user() adds one more check on top: it rejects disabled accounts. This two-layer pattern lets you insert additional checks without touching the token-decoding logic.
- Save auth.py.
Seeing a red underline or import error?
Check that your imports at the top of auth.py include from typing import Annotated and from fastapi import Depends, HTTPException, status. Missing any of these will cause errors in the dependency functions.
Help me fix import errors in auth.py.
Awesome, I've got everything!
Great. Double check you have saved auth.py.
I'd like to double check the full code
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from models import TokenData, User, UserInDB
# Generate your own key with: openssl rand -hex 32
SECRET_KEY = "your-secret-key-here"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
password_hash = PasswordHash.recommended()
DUMMY_HASH = password_hash.hash("dummypassword")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
# In-memory user storage
users_db: dict[str, dict] = {}
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(username: str) -> UserInDB | None:
if username in users_db:
user_dict = users_db[username]
return UserInDB(**user_dict)
return None
def authenticate_user(username: str, password: str) -> UserInDB | bool:
user = get_user(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)],
) -> UserInDB:
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(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)],
) -> UserInDB:
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
Now switch over to main.py. You need a POST /token login endpoint that accepts a username and password, validates them, and returns a JWT token.
- In main.py, replace the existing import lines at the top of the file with these updated imports:
from datetime import timedelta
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
authenticate_user,
create_access_token,
get_current_active_user,
get_password_hash,
users_db,
)
from models import BookmarkCreate, BookmarkResponse, Token, UserCreate, UserInDB
What changed in the imports?
- Depends is added for dependency injection in endpoint parameters.
- OAuth2PasswordRequestForm is FastAPI's built-in form parser for the OAuth2 password flow (username + password via form data).
- The auth import now pulls in the new functions you just created (authenticate_user, create_access_token, get_current_active_user).
- Token and UserInDB are added from models for type hints and response models.
- Below the /register endpoint in main.py, add the POST /token login endpoint:
@app.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
# Validate the username and password
user = authenticate_user(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 code do?
- OAuth2PasswordRequestForm automatically parses the username and password from the request's form data.
- The endpoint calls authenticate_user() to check the credentials. If they are invalid, it returns a 401 Unauthorized error.
- On success, it creates a JWT with the username in the sub claim and returns it as a Token response.
- Save main.py.
- Check the terminal where fastapi dev is running. The server should auto-reload without errors.
Seeing an ImportError in the terminal?
Make sure the function names in your from auth import (...) block match exactly what you defined in auth.py. A common mistake is importing get_current_user instead of get_current_active_user.
Help me fix my import error in main.py.
Protect the bookmark endpoints and test the full flow
The login endpoint works, but the bookmark routes are still open to everyone. You need to add the get_current_active_user dependency to both bookmark endpoints so only authenticated users can access them.
- In main.py, find the create_bookmark function. It currently looks like this:
@app.post("/bookmarks", response_model=BookmarkResponse)
async def create_bookmark(bookmark: BookmarkCreate):
global bookmark_id_counter
bookmark_id_counter += 1
new_bookmark = {
"id": bookmark_id_counter,
"url": bookmark.url,
"title": bookmark.title,
"owner": "anonymous",
}
bookmarks.append(new_bookmark)
return new_bookmark
- Replace the entire create_bookmark function with this updated version:
@app.post("/bookmarks", response_model=BookmarkResponse)
async def create_bookmark(
bookmark: BookmarkCreate,
# Inject the authenticated user as a dependency
current_user: Annotated[UserInDB, Depends(get_current_active_user)],
):
global bookmark_id_counter
bookmark_id_counter += 1
new_bookmark = {
"id": bookmark_id_counter,
"url": bookmark.url,
"title": bookmark.title,
# Set owner from the authenticated user instead of "anonymous"
"owner": current_user.username,
}
bookmarks.append(new_bookmark)
return new_bookmark
What changed?
- current_user: Annotated[UserInDB, Depends(get_current_active_user)] is a new parameter. FastAPI runs get_current_active_user() before the endpoint executes. If the token is missing or invalid, the request is rejected with a 401 error.
- The owner field changed from "anonymous" to current_user.username. Every bookmark now records who created it.
- Now find the get_bookmarks function. It currently looks like this:
@app.get("/bookmarks", response_model=list[BookmarkResponse])
async def get_bookmarks():
return bookmarks
- Replace the entire get_bookmarks function with this updated version:
@app.get("/bookmarks", response_model=list[BookmarkResponse])
async def get_bookmarks(
# Only authenticated users can view bookmarks
current_user: Annotated[UserInDB, Depends(get_current_active_user)],
):
# Filter to only return this user's bookmarks
user_bookmarks = [b for b in bookmarks if b["owner"] == current_user.username]
return user_bookmarks
What changed?
The endpoint now requires authentication and filters the bookmarks list to only return entries where the owner matches the logged-in user. Users can only see their own bookmarks.
- Save main.py.
- Check the terminal. The server should auto-reload without errors.
Server crashing after saving?
Make sure you have from typing import Annotated in your main.py imports. Also confirm that UserInDB is included in the from models import ... line.
Help me fix the crash in main.py after protecting the bookmark endpoints.
Awesome, I've got everything!
Great. Double check you have saved main.py.
I'd like to double check the full code
from datetime import timedelta
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
authenticate_user,
create_access_token,
get_current_active_user,
get_password_hash,
users_db,
)
from models import BookmarkCreate, BookmarkResponse, Token, UserCreate, UserInDB
app = FastAPI(title="Secure Bookmarks API")
# In-memory bookmark storage
bookmarks: list[dict] = []
bookmark_id_counter = 0
@app.post("/register")
async def register(user_data: UserCreate):
if user_data.username in users_db:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
hashed_password = get_password_hash(user_data.password)
users_db[user_data.username] = {
"username": user_data.username,
"email": None,
"disabled": False,
"hashed_password": hashed_password,
}
return {"username": user_data.username, "message": "User created successfully"}
@app.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
user = authenticate_user(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")
@app.post("/bookmarks", response_model=BookmarkResponse)
async def create_bookmark(
bookmark: BookmarkCreate,
current_user: Annotated[UserInDB, Depends(get_current_active_user)],
):
global bookmark_id_counter
bookmark_id_counter += 1
new_bookmark = {
"id": bookmark_id_counter,
"url": bookmark.url,
"title": bookmark.title,
"owner": current_user.username,
}
bookmarks.append(new_bookmark)
return new_bookmark
@app.get("/bookmarks", response_model=list[BookmarkResponse])
async def get_bookmarks(
current_user: Annotated[UserInDB, Depends(get_current_active_user)],
):
user_bookmarks = [b for b in bookmarks if b["owner"] == current_user.username]
return user_bookmarks
Time to test the full authentication flow in the browser. You will register a user, log in to get a token, authorize Swagger with the token, and then create and retrieve bookmarks as that user.
- Open http://127.0.0.1:8000/docs in your browser.
- Expand the POST /register endpoint and click Try it out.
- Enter a username and password in the JSON body, then click Execute. For example:
{
"username": "alice",
"password": "secret123"
}
- You should see a 200 response with the username and a success message.
Now notice something new at the top of the Swagger page. There is an Authorize button with a lock icon. This appeared because you added OAuth2PasswordBearer to your app.
- Click the Authorize button at the top of the page.
- Enter your username in the username field (e.g. alice).
- Enter your password in the password field (e.g. secret123).
- Click Authorize.
Swagger automatically calls your POST /token endpoint behind the scenes and stores the returned JWT. All future requests from Swagger will include the token in the Authorization: Bearer header.
- Click Close to dismiss the authorization dialog.
- Expand POST /bookmarks and click Try it out.
- Enter a bookmark in the JSON body, then click Execute. For example:
{
"url": "https://nextwork.org",
"title": "NextWork"
}
- You should see a 200 response with the bookmark's id, url, title, and owner set to your username (e.g. alice) instead of anonymous.
- Expand GET /bookmarks, click Try it out, then click Execute.
- You should see only the bookmarks you created, filtered to your user.
Getting a 401 Unauthorized response?
Make sure you clicked the Authorize button and entered valid credentials before calling the bookmark endpoints. If you restarted the server, the in-memory user data is gone. Register a new user and authorize again.
Also check that SECRET_KEY in auth.py is a real hex string, not the placeholder "your-secret-key-here".
Help me debug my 401 Unauthorized error when calling the bookmark endpoints.
Your API now requires authentication for every bookmark operation. Users can only see and create their own bookmarks. Next up, you will add rate limiting and CORS to defend against brute-force attacks and unauthorized cross-origin requests.
Add Rate Limiting and CORS
Your API now authenticates users with JWT tokens and protects stored passwords with Argon2 hashing.
Authentication alone doesn't stop every type of attack, though. An attacker could still spam your login endpoint with thousands of password guesses per minute. A malicious website could also make cross-origin requests to your API without restriction. In this step, you'll add rate limiting and CORS to close these gaps.
In this step, get ready to:
- Add rate limiting to prevent brute-force attacks on your endpoints.
- Add CORS middleware to control which origins can access your API.
- Test both features to verify they block abuse and unauthorized origins.
Set up rate limiting with slowapi
Rate limiting caps how many requests a single client can make to an endpoint within a time window. Without it, an attacker could try thousands of login passwords per minute until one works.
- In main.py, replace the entire import section at the top of the file with these updated imports:
from datetime import timedelta
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordRequestForm
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
authenticate_user,
create_access_token,
get_current_active_user,
get_password_hash,
users_db,
)
from models import BookmarkCreate, BookmarkResponse, Token, UserCreate, UserInDB
What's new in these imports?
- Request is added to the fastapi import. Every rate-limited endpoint needs this as an explicit parameter so slowapi can track the client's IP address.
- CORSMiddleware is a built-in FastAPI middleware you'll use in the next substep.
- The three slowapi imports bring in the rate limiter itself, an error handler, and a function that identifies clients by IP address.
- Now find the line that says app = FastAPI(title="Secure Bookmarks API").
- Replace that single line with these four lines to initialize the limiter and attach it to your app:
# Create a rate limiter that identifies clients by IP address
limiter = Limiter(key_func=get_remote_address)
app = FastAPI(title="Secure Bookmarks API")
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
What does this code do?
- Limiter(key_func=get_remote_address) creates the limiter. The key_func tells it to track each client by their IP address.
- app.state.limiter = limiter stores the limiter on the app so slowapi can access it during request handling.
- The exception handler converts rate limit violations into a proper 429 Too Many Requests response instead of crashing.
The limiter infrastructure is ready. Now you need to tell it which endpoints to protect and how aggressively.
- Each endpoint needs two changes: a @limiter.limit() decorator and a request: Request parameter.
- Find the /register endpoint. It currently looks like this:
@app.post("/register")
async def register(user_data: UserCreate):
- Add the @limiter.limit("10/minute") decorator on a new line below the @app.post("/register") line.
- Add request: Request as the first parameter of the function, before user_data.
- The updated signature should look like this:
@app.post("/register")
@limiter.limit("10/minute")
async def register(request: Request, user_data: UserCreate):
Decorator order matters
The route decorator (@app.post) must always be above the limit decorator (@limiter.limit). Swapping them breaks the rate limiting silently.
- Apply the same pattern to the remaining three endpoints. Update the /token endpoint signature to match this:
@app.post("/token")
@limiter.limit("5/minute")
async def login_for_access_token(
request: Request,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
- Update the POST /bookmarks endpoint signature to match this:
@app.post("/bookmarks", response_model=BookmarkResponse)
@limiter.limit("30/minute")
async def create_bookmark(
request: Request,
bookmark: BookmarkCreate,
current_user: Annotated[UserInDB, Depends(get_current_active_user)],
):
- Update the GET /bookmarks endpoint signature to match this:
@app.get("/bookmarks", response_model=list[BookmarkResponse])
@limiter.limit("30/minute")
async def get_bookmarks(
request: Request,
current_user: Annotated[UserInDB, Depends(get_current_active_user)],
):
Why different rate limits for each endpoint?
- /token gets the strictest limit (5/minute) because it's the login endpoint. Brute-force password guessing targets this endpoint directly.
- /register gets 10/minute to prevent mass account creation while still letting legitimate users sign up.
- The bookmark endpoints get 30/minute because normal usage involves more frequent reads and writes.
- Save main.py.
You won't test this yet. The next substep adds CORS, and then you'll test everything together.
Seeing an import error when the server reloads?
- Check that every import line matches the code above exactly. A missing comma or typo in _rate_limit_exceeded_handler (note the leading underscore) causes an ImportError.
- Confirm that slowapi is installed in your virtual environment. If not, run pip install slowapi again.
- Help me debug my slowapi import error.
Add CORS middleware
CORS (Cross-Origin Resource Sharing) controls which websites can make requests to your API. Without it, any website on the internet could call your endpoints from a user's browser.
- In main.py, find the line that says app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler).
- Add the following CORS middleware configuration directly below it:
# Only allow requests from this specific frontend origin
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["Authorization"],
)
What does each CORS setting do?
- allow_origins lists which website domains can call your API. Only http://localhost:3000 is allowed here (a typical frontend dev server address).
- allow_credentials=True permits the browser to send cookies and authentication headers with cross-origin requests.
- allow_methods restricts which HTTP methods are allowed. Only GET, POST, and DELETE can be used.
- allow_headers specifies which custom headers cross-origin requests can include. The Authorization header is needed to send JWT bearer tokens.
Pro tip: never use a wildcard in production
You might see tutorials that set allow_origins=["*"] to allow all origins. This disables CORS protection entirely. Always list specific origins in production so only your own frontend can access the API.
- Save main.py.
- Check your terminal where fastapi dev main.py is running. The development server should auto-reload without errors.
Server showing an error after reload?
- Make sure the app.add_middleware(...) call is placed after app = FastAPI(...) and the exception handler, not before them.
- Verify that from fastapi.middleware.cors import CORSMiddleware is in your import section.
- Help me fix my CORS middleware error.
Awesome, I've got everything!
Double check you've saved main.py and that your server reloaded without errors.
I'd like to double check the full code
Here is the complete main.py with all rate limiting and CORS changes applied.
from datetime import timedelta
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordRequestForm
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
authenticate_user,
create_access_token,
get_current_active_user,
get_password_hash,
users_db,
)
from models import BookmarkCreate, BookmarkResponse, Token, UserCreate, UserInDB
limiter = Limiter(key_func=get_remote_address)
app = FastAPI(title="Secure Bookmarks API")
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["Authorization"],
)
# In-memory bookmark storage
bookmarks: list[dict] = []
bookmark_id_counter = 0
@app.post("/register")
@limiter.limit("10/minute")
async def register(request: Request, user_data: UserCreate):
if user_data.username in users_db:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
hashed_password = get_password_hash(user_data.password)
users_db[user_data.username] = {
"username": user_data.username,
"email": None,
"disabled": False,
"hashed_password": hashed_password,
}
return {"username": user_data.username, "message": "User created successfully"}
@app.post("/token")
@limiter.limit("5/minute")
async def login_for_access_token(
request: Request,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
user = authenticate_user(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")
@app.post("/bookmarks", response_model=BookmarkResponse)
@limiter.limit("30/minute")
async def create_bookmark(
request: Request,
bookmark: BookmarkCreate,
current_user: Annotated[UserInDB, Depends(get_current_active_user)],
):
global bookmark_id_counter
bookmark_id_counter += 1
new_bookmark = {
"id": bookmark_id_counter,
"url": bookmark.url,
"title": bookmark.title,
"owner": current_user.username,
}
bookmarks.append(new_bookmark)
return new_bookmark
@app.get("/bookmarks", response_model=list[BookmarkResponse])
@limiter.limit("30/minute")
async def get_bookmarks(
request: Request,
current_user: Annotated[UserInDB, Depends(get_current_active_user)],
):
user_bookmarks = [b for b in bookmarks if b["owner"] == current_user.username]
return user_bookmarks
Test rate limiting and CORS
Both security layers are in place. Time to prove they actually work by triggering a rate limit and checking for CORS headers.
- Open your browser and go to http://127.0.0.1:8000/docs to open the Swagger UI.
- Expand the POST /token endpoint.
- Click Try it out.
- Enter any username and password in the form fields (they don't need to be valid for this test).
- Click Execute rapidly six or more times within one minute.
The first five requests return a 401 Unauthorized response (because the credentials are wrong). The sixth request returns 429 Too Many Requests with a Rate limit exceeded message. That's your rate limiter doing its job.
Why test with invalid credentials?
Rate limiting counts every request regardless of whether it succeeds. This is exactly what you want. An attacker doesn't know the correct password, so they'll be sending invalid credentials. The limiter blocks them after 5 attempts.
Now verify that CORS headers are being added to responses. Open a new terminal window and run this command:
- Send a request with a matching Origin header by running:
curl -s -D - -o /dev/null -H "Origin: http://localhost:3000" http://127.0.0.1:8000/docs
What does this command do?
- curl sends an HTTP request from your terminal.
- -D - prints the response headers to your terminal.
- -o /dev/null hides the response body so you only see the headers.
- -H "Origin: http://localhost:3000" simulates a request coming from a frontend running at that address.
- Look at the response headers in your terminal output.
You should see access-control-allow-origin: http://localhost:3000 in the headers. This confirms that your API is sending CORS headers for the allowed origin.
Not seeing the CORS header?
- Make sure you included the -H "Origin: http://localhost:3000" flag. CORS headers only appear when the request includes an Origin header that matches the allowed list.
- Verify the allow_origins list in your middleware uses the exact string http://localhost:3000 (no trailing slash).
- On Windows, if curl is not available, open PowerShell and try curl.exe instead.
- Help me verify my CORS setup.
Your Bookmarks API is now fully secured. It authenticates users with JWT tokens, hashes passwords with Argon2, blocks abusive traffic with rate limiting, and restricts cross-origin access with CORS. That's a production-grade security stack built from scratch. Ready for a bonus challenge? The Secret Mission lets you add role-based access control so admin users get special powers.
Secret mission
Add Role-Based Access Control (RBAC)
Your API currently treats every authenticated user the same. In the real world, some users need elevated privileges. Add role-based access control so admins can manage all bookmarks while regular users can only touch their own.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Project directory with virtual environment.
- Python files: main.py, auth.py, models.py.
- Installed pip packages: fastapi, pyjwt, pwdlib, slowapi.
- FastAPI development server (running process).
Keep everything running
No action needed. Choose this if you're still actively building or want to continue experimenting with the API.
- Your FastAPI dev server will keep running until you close the terminal or press Ctrl+C.
- Your virtual environment and project files stay on disk, ready to use.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files and virtual environment will stay on disk for next time.
- Stop the FastAPI dev server by pressing Ctrl+C in the terminal.
- Deactivate your virtual environment by running:
deactivate
When you come back, navigate to your project directory, reactivate the virtual environment, and start the server again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your code and virtual environment permanently.
- Stop the FastAPI dev server by pressing Ctrl+C in the terminal.
- Deactivate your virtual environment by running:
deactivate
- Navigate to the parent directory and delete the entire project folder.
macOS
This command also works on Linux.
cd ..
rm -rf secure-bookmarks-api
Windows
cd ..
rmdir /s /q secure-bookmarks-api
That removes the project directory, virtual environment, and all installed packages.
Mission accomplished
Nice Work!
You just built a fully secured REST API from scratch using FastAPI, JWT authentication, and real-world security best practices. Your Bookmarks API went from completely open to production-grade secure, one layer at a time.
You learned how to:
- Implemented JWT token-based authentication with the OAuth2 password flow, issuing and validating bearer tokens to protect private API routes.
- Secured user credentials with Argon2 password hashing and timing-attack protection, so passwords are never stored in plaintext.
- Locked down the API with rate limiting to prevent brute-force attacks and CORS middleware to control which origins can call your endpoints.
- Added role-based access control (RBAC) so admin users can manage any bookmark while regular users can only access their own.
Ready to quiz yourself?