Secure a REST API with FastAPI and JWT

Build a JWT-authenticated REST API with role-based access control in Python.

Introduction

30 Second Summary

Every time you log into an app, something behind the scenes verifies who you are and decides what you're allowed to do. That "something" is API authentication.

In this project, you will build a secure REST API that issues tokens to verified users and controls access to resources based on their role.

What You'll Build

A "Book Library" REST API built with FastAPI, secured with JWT-based authentication and role-based access control.

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

  • A REST API with user registration, login, and automatic Swagger documentation.
  • JWT-based authentication that issues access tokens and protects endpoints from unauthorized requests.
  • Role-based access control that restricts admin-only actions like creating and deleting books.
  • Secret Mission: Add a refresh token system so users can stay logged in without re-entering credentials.

Are there any prerequisites?

You should be comfortable with basic Python concepts like functions, classes, and dictionaries. No prior experience with FastAPI or API security is required.

Before We Start

Every API that handles user data needs a way to verify who is making each request. Without authentication, anyone could access, modify, or delete data they should not touch.

In this step, you will set up your Python environment and install the libraries that power your secure API. By the end, you will have a working project folder with FastAPI, PyJWT, and pwdlib ready to go.

In this step, get ready to:
  • Install Python and create a virtual environment.
  • Install FastAPI, PyJWT, and pwdlib.
  • Create your project file and verify the server runs.
Install Python and set up your project folder

This project requires Python 3.10 or higher. Let's check what version you have installed.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then typing Terminal and pressing Enter.
  • Check your Python version by running this command:
python3 --version

✔️ I see version 3.10 or higher

Your Python version is ready. Continue to the next instruction below.

ⓧ I see an older version

You have Python installed, but it is below 3.10. macOS often ships with an older default version.

  • Download the latest Python installer from python.org/downloads.
  • Run the installer and follow the prompts.
  • On Windows, check the box that says Add Python to PATH before clicking Install Now.
  • Close and reopen your terminal, then run python3 --version again to confirm.

ⓧ Command not found

Python is not installed on your machine yet.

  • Download the latest Python installer from python.org/downloads.
  • Run the installer and follow the prompts.
  • On Windows, check the box that says Add Python to PATH before clicking Install Now.
  • Close and reopen your terminal, then run python3 --version to confirm the installation.
  • 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
  • Create a virtual environment by running this command:
python3 -m venv venv

What is a virtual environment?

A virtual environment is an isolated Python workspace for your project. It keeps your project's packages separate from your system-wide Python installation, so different projects can use different library versions without conflicts.

  • Activate the virtual environment:

macOS

source venv/bin/activate

Windows

.\venv\Scripts\Activate.ps1

Seeing an execution policy error?

PowerShell may block scripts by default. Run this command first, then try activating again:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

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

Don't see (venv) in your prompt?

Make sure you ran the activate command from inside the secure-book-api folder. The venv folder must exist in your current directory.

Help me troubleshoot virtual environment activation

Install your project dependencies

Your secure API relies on three core libraries. FastAPI handles HTTP routing and automatic documentation. PyJWT creates and verifies JSON Web Tokens. pwdlib hashes passwords securely using Argon2.

  • Install all three packages by running this command:
pip install "fastapi[standard]" PyJWT 'pwdlib[argon2]'

What are these packages?

  • fastapi[standard] installs FastAPI plus its recommended extras (including the development server and form data parsing).
  • PyJWT is the library that encodes and decodes JWT tokens using the HS256 algorithm.
  • pwdlib[argon2] provides password hashing with Argon2, the winner of the Password Hashing Competition.

You should see a series of download and installation messages ending with a success confirmation.

Seeing installation errors?

Make sure (venv) is visible in your terminal prompt before running pip install. If you see a permissions error, you may have forgotten to activate the virtual environment.

Help me fix pip installation errors

Create main.py and verify FastAPI runs

With your packages installed, create the single file that will hold your entire API.

  • Create an empty main.py file by running this command:
touch main.py
  • Confirm the file exists by running:
ls

You should see main.py and the venv folder in the output.

  • Open your code editor by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Cursor (or VS Code), and pressing Enter.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to Desktop and select the secure-book-api folder, then click Open.
  • Click on main.py in the file explorer sidebar to open it.
  • Add the following starter code to main.py:
from fastapi import FastAPI

# Create the FastAPI application
app = FastAPI(title="Secure Book Library API")

What does this code do?

  • from fastapi import FastAPI imports the main class from the FastAPI framework.
  • app = FastAPI(title="Secure Book Library API") creates your application instance. The title appears in the auto-generated documentation page.
  • Save main.py.
  • Switch back to your terminal and start the FastAPI development server by running:
fastapi dev main.py

You should see output showing that Uvicorn is running at http://127.0.0.1:8000.

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

You should see the FastAPI Swagger documentation page with the title "Secure Book Library API" at the top.

Don't see the Swagger page?

Make sure the terminal is still running the fastapi dev command. If you see an error about no application found, confirm your main.py has the exact code shown above (including the app variable name).

Help me get the Swagger docs working

  • Stop the server by pressing Ctrl+C in your terminal.

Your development environment is ready to go. Next up, you will build the book API endpoints that users will eventually need authentication to access.

Build the Unsecured Book API

Your Python environment is set up and ready to go. Now it's time to build something you can actually interact with.

You'll create a REST API using FastAPI that lets users view, add, and delete books from a library. This first version won't have any security. Anyone can do anything. That's intentional. Seeing the API wide open makes it crystal clear why authentication matters in the next steps.

In this step, get ready to:
  • Define Pydantic models for book data and seed an in-memory database.
  • Build CRUD endpoints for listing, fetching, creating, and deleting books.
  • Test your API using FastAPI's built-in Swagger UI.
Create Pydantic models and an in-memory database

Before writing endpoints, you need two things: data models that define what a book looks like, and some seed data to serve. Pydantic models give FastAPI the ability to validate incoming data and automatically generate documentation.

  • In your code editor, open main.py inside your project folder.
  • Replace any existing content with this foundation code:
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel

app = FastAPI(title="Secure Book Library API")

# --- Models ---
class Book(BaseModel):
    id: int
    title: str
    author: str

class BookCreate(BaseModel):
    title: str
    author: str

# --- In-Memory Database ---
fake_books_db: list[dict] = [
    {"id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald"},
    {"id": 2, "title": "To Kill a Mockingbird", "author": "Harper Lee"},
]

book_id_counter = 3

What does this code do?

The Book model defines the shape of a complete book (with an ID). BookCreate defines what the user sends when adding a new book (no ID, because the server assigns one).

The fake_books_db list holds two seed books in memory. The book_id_counter tracks the next available ID so each new book gets a unique number.

  • Save main.py.

You won't see a visible change yet. The next chunk adds endpoints that use these models.

Build the CRUD endpoints

CRUD stands for Create, Read, Update, Delete. These are the four basic operations any API provides for managing data. You'll build the Read endpoints first, then the Create and Delete endpoints.

  • Add the following GET endpoints at the bottom of main.py:
# --- Book 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=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )

What does this code do?

The @app.get decorator registers a function as a GET endpoint. FastAPI uses the response_model parameter to validate the response shape and generate documentation automatically.

The first endpoint returns all books. The second takes a book_id from the URL path, searches the list, and returns the matching book. If no book matches, it raises a 404 error.

  • Save main.py.
  • Start your FastAPI dev server by running this command in your terminal (with your virtual environment active):
fastapi dev main.py
  • Open your browser and go to http://127.0.0.1:8000/docs.
  • You should see the Swagger UI with your API title "Secure Book Library API" and two GET endpoints listed: /books and /books/{book_id}.

Don't see your endpoints in Swagger?

  • Check your terminal for error messages. A syntax error (missing colon, wrong indentation) prevents the server from starting.
  • Make sure you saved main.py before checking the browser. The dev server only reloads on save.
  • Confirm the endpoints are placed after the app = FastAPI(...) line. Endpoints defined before the app object don't register.

Help me debug why my FastAPI endpoints aren't showing in Swagger UI.

  • Add the POST endpoint below your GET endpoints in main.py:
@app.post("/books", response_model=Book, status_code=status.HTTP_201_CREATED)
async def create_book(book_data: BookCreate):
    global book_id_counter
    new_book = {"id": book_id_counter, "title": book_data.title, "author": book_data.author}
    fake_books_db.append(new_book)
    book_id_counter += 1
    return new_book

What does this code do?

The @app.post decorator registers a POST endpoint. The status_code=status.HTTP_201_CREATED tells FastAPI to return a 201 status (meaning "resource created") instead of the default 200.

The function receives a BookCreate object (FastAPI validates the request body automatically), assigns the next available ID, appends the book to the database, and increments the counter.

  • Save main.py.
  • Refresh your browser at http://127.0.0.1:8000/docs. You should now see a POST /books endpoint listed alongside the GET endpoints.

POST endpoint not appearing?

  • Make sure there is no indentation before the @app.post decorator. Decorators must be at the module level (no leading spaces).
  • Check that BookCreate is defined above the endpoint function. Python reads top to bottom, so the model must exist before the function references it.

Help me figure out why my POST endpoint isn't showing up.

  • Add the DELETE endpoint below the POST endpoint in main.py:
@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book(book_id: int):
    for i, book in enumerate(fake_books_db):
        if book["id"] == book_id:
            fake_books_db.pop(i)
            return
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )

What does this code do?

The DELETE endpoint uses enumerate() to loop through the list with both the index and the book dictionary. When it finds a matching ID, it removes the book with pop(i) and returns immediately.

The HTTP_204_NO_CONTENT status code means the deletion succeeded but there's no response body to send back. If no book matches, it raises a 404 just like the GET endpoint.

  • Save main.py.
  • Refresh your browser at http://127.0.0.1:8000/docs. You should now see all four endpoints: GET /books, GET /books/{book_id}, POST /books, and DELETE /books/{book_id}.

Seeing a server error in the terminal?

  • A common cause is a missing comma in the raise HTTPException(...) call. Make sure each keyword argument is separated by a comma.
  • Check that the for loop body is indented one level deeper than the for statement itself.

Help me fix the syntax error in my DELETE endpoint.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py and all four endpoints are visible in Swagger.

ⓧ I'd like to double check the full code

Here's the complete main.py at this point. Compare yours against it:

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel

app = FastAPI(title="Secure Book Library API")

# --- Models ---
class Book(BaseModel):
    id: int
    title: str
    author: str

class BookCreate(BaseModel):
    title: str
    author: str

# --- In-Memory Database ---
fake_books_db: list[dict] = [
    {"id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald"},
    {"id": 2, "title": "To Kill a Mockingbird", "author": "Harper Lee"},
]

book_id_counter = 3

# --- Book 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=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )

@app.post("/books", response_model=Book, status_code=status.HTTP_201_CREATED)
async def create_book(book_data: BookCreate):
    global book_id_counter
    new_book = {"id": book_id_counter, "title": book_data.title, "author": book_data.author}
    fake_books_db.append(new_book)
    book_id_counter += 1
    return new_book

@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book(book_id: int):
    for i, book in enumerate(fake_books_db):
        if book["id"] == book_id:
            fake_books_db.pop(i)
            return
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )
Test the API in Swagger UI

FastAPI generates an interactive documentation page called Swagger UI. You can send real requests to your API directly from the browser without writing any client code.

  • In your browser at http://127.0.0.1:8000/docs, click the GET /books row to expand it.
  • Click Try it out, then click Execute.
  • You should see a JSON response with two books: "The Great Gatsby" and "To Kill a Mockingbird".
  • Expand the POST /books row and click Try it out.
  • Replace the request body with this JSON:
{
  "title": "1984",
  "author": "George Orwell"
}
  • Click Execute. You should see a 201 response with the new book and "id": 3 assigned automatically.
  • Go back to GET /books and execute it again. You should now see three books in the response.
  • Expand DELETE /books/{book_id}, click Try it out, enter 3 for the book_id, and click Execute. You should see a 204 response (no content).

Notice anything about security?

Every single endpoint is completely open. Anyone who finds this API can delete every book in the database without logging in. There's no authentication, no authorization, and no way to know who made a request.

In a real application, this would be a serious vulnerability. The next steps fix exactly this problem.

Your Book API is alive and functional. Next up, you'll add user models and password hashing to start locking things down.

Add User Models and Password Hashing

Your Book API works, but right now anyone can add or delete books with zero accountability. Real-world APIs need to know who is making requests before deciding what they are allowed to do.

In this step, you'll create user models and set up password hashing so the API can safely store credentials without ever keeping plaintext passwords. This is the foundation that the authentication layer in the next step will build on.

In this step, get ready to:
  • Create Pydantic models for users with roles and hashed passwords.
  • Implement password hashing with Argon2 so passwords are never stored in plain text.
  • Test user registration in the Swagger UI and verify hashed output.
Create User and UserInDB Pydantic models

Before you can register users, the API needs to know what a "user" looks like. You'll define four models that represent users at different stages of their lifecycle.

  • In main.py, find the existing import line at the top of the file:
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
  • Replace those two import lines with the following code, which adds the pwdlib import, creates the password hashing instance, and defines your user models above the existing Book models:
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from pwdlib import PasswordHash

app = FastAPI(title="Secure Book Library API")

# --- Password Hashing ---
password_hash = PasswordHash.recommended()

# --- Models ---
class UserBase(BaseModel):
    username: str
    email: str
    role: str = "user"

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

class User(UserBase):
    disabled: bool = False

class UserInDB(User):
    hashed_password: str

class Book(BaseModel):
    id: int
    title: str
    author: str

class BookCreate(BaseModel):
    title: str
    author: str

# --- In-Memory Database ---
fake_users_db: dict[str, dict] = {}

fake_books_db: list[dict] = [
    {"id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald"},
    {"id": 2, "title": "To Kill a Mockingbird", "author": "Harper Lee"},
]

book_id_counter = 3

What does this code do?

PasswordHash.recommended() creates a hasher that uses Argon2, the winner of the Password Hashing Competition. Argon2 is memory-hard, meaning it's expensive for attackers to brute-force even with GPUs.

The four user models each serve a different role:

  • UserBase holds the shared fields (username, email, role).
  • UserCreate is what the API receives from the client (includes the plain password).
  • User is what the API sends back to the client (no password at all).
  • UserInDB is what gets stored internally (includes the hashed password, never the plain one).

fake_users_db is an empty dictionary that will store registered users keyed by their username.

  • Save main.py.
  • Check the terminal where fastapi dev is running. The server should auto-reload without errors.

Seeing a ModuleNotFoundError for pwdlib?

Make sure you installed pwdlib with Argon2 support in Step 1. Activate your virtual environment and run:

pip install 'pwdlib[argon2]'

If the server still fails, double check that the import reads from pwdlib import PasswordHash (not from pwdlib import passwordhash).

Help me debug this pwdlib import error.

Implement password hashing and the registration endpoint

With the models in place, you need two utility functions that wrap the hashing library. Then you'll build the registration endpoint that puts it all together.

  • In main.py, find the line that says book_id_counter = 3. Add the following utility functions below it:
# --- Auth Utilities ---
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)

What do these functions do?

verify_password() checks whether a plain-text password matches a stored hash. It returns True or False without ever exposing the original password.

get_password_hash() takes a plain-text password and returns its Argon2 hash. You'll call this during registration to store only the hashed version.

  • Now add the registration endpoint. Place this code below the get_password_hash function and above the # --- Book Endpoints --- comment:
# --- Auth Endpoints ---
@app.post("/register", response_model=User)
async def register_user(user_data: UserCreate):
    # Check if the username is already taken
    if user_data.username in fake_users_db:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Username already registered",
        )
    # Hash the password before storing
    hashed = get_password_hash(user_data.password)
    user_dict = {
        "username": user_data.username,
        "email": user_data.email,
        "role": "user",
        "disabled": False,
        "hashed_password": hashed,
    }
    fake_users_db[user_data.username] = user_dict
    return User(**user_dict)

What does this endpoint do?

The /register endpoint accepts a UserCreate payload (username, email, password). It first checks whether the username is already taken and returns a 400 error if so.

If the username is available, it hashes the password with Argon2 and stores the full user record in fake_users_db. The response uses the User model, which deliberately excludes the hashed password from the JSON that gets sent back to the client.

  • Save main.py.
  • Check the terminal for a clean reload. You should see Application startup complete with no errors.

Seeing an indentation or syntax error?

Make sure the # --- Auth Endpoints --- comment sits between the utility functions and the # --- Book Endpoints --- section. Check that the @app.post("/register") decorator is at the top-level indentation (no leading spaces).

Help me fix a syntax error in my registration endpoint.

Test user registration and verify Argon2 hashes

The best way to confirm your registration works is to call it and inspect the result. You'll also peek at the internal database to prove passwords are stored as hashes.

  • Switch back to the Swagger UI in your browser at http://127.0.0.1:8000/docs.
  • Find the new POST /register endpoint in the list.
  • Click on it to expand it, then click Try it out.
  • Replace the example JSON in the request body with:
{
  "username": "alice",
  "email": "alice@example.com",
  "password": "secretpass123"
}
  • Click Execute.

You should see a 200 response with the user data. Notice that the response does NOT include the password or the hashed password.

Why is the password missing from the response?

The response_model=User parameter tells FastAPI to only include fields defined in the User model (username, email, role, disabled). The hashed password exists internally but is never sent back to the client. This is a core security pattern.

Now verify that the password was actually hashed internally. You'll add a temporary debug line to confirm.

  • Return to main.py and add this temporary print statement inside the register_user function, just before the return line:
    print(f"Stored hash: {hashed}")
  • Save main.py and register another test user in Swagger UI (use a different username like bob).
  • Check the terminal output. You should see something like:
Stored hash: $argon2id$v=19$m=65536,t=3,p=4$...

The long string starting with $argon2id$ confirms the password was hashed with Argon2. No trace of the original plaintext password exists in storage.

  • Now remove the print statement you just added. It was only for verification.
  • Save main.py.

Try registering the same username twice

Go back to Swagger UI and try to register alice again. You should get a 400 error with the message Username already registered. This proves the duplicate-check logic works.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and the print statement is removed.

ⓧ I'd like to double check the full code

Here is the complete main.py file at the end of this step:

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from pwdlib import PasswordHash

app = FastAPI(title="Secure Book Library API")

# --- Password Hashing ---
password_hash = PasswordHash.recommended()

# --- Models ---
class UserBase(BaseModel):
    username: str
    email: str
    role: str = "user"

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

class User(UserBase):
    disabled: bool = False

class UserInDB(User):
    hashed_password: str

class Book(BaseModel):
    id: int
    title: str
    author: str

class BookCreate(BaseModel):
    title: str
    author: str

# --- In-Memory Database ---
fake_users_db: dict[str, dict] = {}

fake_books_db: list[dict] = [
    {"id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald"},
    {"id": 2, "title": "To Kill a Mockingbird", "author": "Harper Lee"},
]

book_id_counter = 3

# --- Auth Utilities ---
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)

# --- Auth Endpoints ---
@app.post("/register", response_model=User)
async def register_user(user_data: UserCreate):
    if user_data.username in fake_users_db:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Username already registered",
        )
    hashed = get_password_hash(user_data.password)
    user_dict = {
        "username": user_data.username,
        "email": user_data.email,
        "role": "user",
        "disabled": False,
        "hashed_password": hashed,
    }
    fake_users_db[user_data.username] = user_dict
    return User(**user_dict)

# --- Book 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=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )

@app.post("/books", response_model=Book, status_code=status.HTTP_201_CREATED)
async def create_book(book_data: BookCreate):
    global book_id_counter
    new_book = {"id": book_id_counter, "title": book_data.title, "author": book_data.author}
    fake_books_db.append(new_book)
    book_id_counter += 1
    return new_book

@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book(book_id: int):
    for i, book in enumerate(fake_books_db):
        if book["id"] == book_id:
            fake_books_db.pop(i)
            return
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )

Your API can now register users and safely hash their passwords. Next up, you'll use these stored credentials to issue JWT tokens and lock down your endpoints.

Implement JWT Authentication

Your Book Library API can register users and hash passwords securely. But right now, anyone can hit the book endpoints without proving who they are.

In this step, you'll add JWT authentication. The API will issue a signed token when a user logs in, and protected endpoints will require that token on every request.

In this step, get ready to:
  • Set up the OAuth2 password flow and JWT configuration.
  • Build a /token endpoint that issues JWT access tokens.
  • Create a dependency that verifies tokens on protected routes.
Set up the OAuth2 password flow and token configuration

Before you can issue tokens, you need to configure three things: the signing secret, the algorithm, and how FastAPI discovers the token on incoming requests.

  • In main.py, find the import lines at the very top of the file:
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from pwdlib import PasswordHash
  • Replace those three lines with the following expanded imports:
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 pydantic import BaseModel
from pwdlib import PasswordHash

What are these new imports?

  • datetime modules give tokens an expiration timestamp.
  • jwt (PyJWT) encodes and decodes JSON Web Tokens.
  • Depends is FastAPI's dependency injection tool. It lets you declare reusable logic that runs before your endpoint function.
  • OAuth2PasswordBearer tells FastAPI where to look for the bearer token on requests.
  • OAuth2PasswordRequestForm parses login form data (username + password) from the request body.
  • Now find the app = FastAPI(title="Secure Book Library API") line (currently right below the imports).
  • Replace that single line with the following configuration section:
# --- Configuration ---
SECRET_KEY = "your-secret-key-here"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

What is the SECRET_KEY?

The SECRET_KEY is used to digitally sign your JWT tokens. Anyone who knows this key can forge valid tokens, so in production you would generate a strong random value.

You can generate a production-grade secret by running this in your terminal:

openssl rand -hex 32

For this project, the placeholder value is fine. HS256 means HMAC with SHA-256, a standard symmetric signing algorithm for single-server setups.

  • Next, find the # --- Models --- comment. Add these two new model classes right below that comment, above your existing UserBase class:
class Token(BaseModel):
    access_token: str
    token_type: str

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

What do these models represent?

Token defines the shape of the JSON response when a user logs in successfully. The OAuth2 spec requires an access_token field and a token_type field.

TokenData is used internally to hold the username extracted from a decoded token.

  • Now scroll down to the # --- Auth Utilities --- comment. Right below that comment (and above the verify_password function), add these two lines:
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

app = FastAPI(title="Secure Book Library API")

Why move the app declaration here?

OAuth2PasswordBearer tells FastAPI's interactive docs where the login endpoint lives. The tokenUrl="token" parameter points to a /token path you'll create shortly.

The app instance is moved here so all auth configuration is grouped together in one section, keeping the file organized as it grows.

  • Save main.py.

Now you'll add three utility functions that form the authentication pipeline: looking up a user, verifying credentials, and creating tokens.

  • In main.py, find the get_password_hash function. Add this new function below it:
def get_user(db: dict, username: str) -> UserInDB | None:
    if username in db:
        user_dict = db[username]
        return UserInDB(**user_dict)
    return None

What does this function do?

get_user looks up a username in the database dictionary. If found, it converts the raw dictionary into a typed UserInDB object. If not found, it returns None.

  • Add the authenticate_user function below get_user:
def authenticate_user(db: dict, username: str, password: str) -> UserInDB | bool:
    user = get_user(db, username)
    if not user:
        return False
    if not verify_password(password, user.hashed_password):
        return False
    return user

What does this function do?

authenticate_user combines two checks: does the user exist, and does the password match? It returns the user object on success, or False if either check fails.

  • Add the create_access_token function below authenticate_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

What does this function do?

create_access_token takes a dictionary of claims (like the username), adds an expiration timestamp, then signs everything with your SECRET_KEY using the HS256 algorithm.

The exp claim is a standard JWT field. Any library decoding the token will automatically reject it once the timestamp passes.

  • Save main.py.

Seeing a red underline or import error?

Make sure your import block at the top has import jwt (not import PyJWT). The package is installed as PyJWT but imported as jwt.

help me fix import errors in my FastAPI JWT project

Build the POST /token endpoint

The /token endpoint is where users exchange their username and password for a JWT access token. This is the standard OAuth2 password flow.

  • In main.py, find the register_user endpoint. Add the following /token endpoint below it (but above the # --- Book Endpoints --- comment):
@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, "role": user.role},
        expires_delta=access_token_expires,
    )
    return Token(access_token=access_token, token_type="bearer")

What does this endpoint do?

  • OAuth2PasswordRequestForm automatically parses username and password fields from the form data in the request body.
  • If authentication fails, the endpoint returns a 401 with the required WWW-Authenticate: Bearer header (this is part of the OAuth2 spec).
  • On success, it packs the username into the sub (subject) claim and the user's role into the token payload, then returns the signed token.
  • Save main.py.
  • Switch to your browser and refresh the Swagger UI at http://127.0.0.1:8000/docs.
  • You should see a new POST /token endpoint in the docs, plus an Authorize button at the top of the page.

Don't see the /token endpoint?

Make sure the @app.post("/token") decorator is placed after the app = FastAPI(...) line. If app is not defined yet when Python reads the decorator, you'll get a NameError.

help me debug why my /token endpoint isn't showing in Swagger

Create the get_current_user dependency

Issuing tokens is only half the story. You also need a way to verify the token on every incoming request. FastAPI's dependency injection system makes this clean: you define a function once and attach it to any endpoint that needs protection.

  • In main.py, find the create_access_token function. Add this new async function below it (still inside the # --- Auth Utilities --- section, above # --- Auth Endpoints ---):
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(fake_users_db, username=token_data.username)
    if user is None:
        raise credentials_exception
    return user

What does this dependency do?

  • Depends(oauth2_scheme) automatically extracts the Bearer token from the request's Authorization header.
  • jwt.decode verifies the signature and checks that the token has not expired.
  • The sub claim holds the username. If it is missing, tampered with, or belongs to a deleted user, the function raises a 401.
  • Any endpoint that adds Depends(get_current_user) to its parameters will require a valid token before running.
  • Save main.py.
  • Check your terminal where fastapi dev is running. The server should auto-reload without errors.

You should see a line like Application startup complete in the terminal output.

Seeing a server error on reload?

Check that get_current_user is defined after oauth2_scheme and after get_user. Python reads files top-to-bottom, so referencing a name before it is defined causes a NameError.

help me fix the server error after adding get_current_user

✔️ Awesome, I've got everything!

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

ⓧ 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, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pydantic import BaseModel
from pwdlib import PasswordHash

# --- Configuration ---
SECRET_KEY = "your-secret-key-here"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

# --- Password Hashing ---
password_hash = PasswordHash.recommended()

# --- Models ---
class Token(BaseModel):
    access_token: str
    token_type: str

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

class UserBase(BaseModel):
    username: str
    email: str
    role: str = "user"

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

class User(UserBase):
    disabled: bool = False

class UserInDB(User):
    hashed_password: str

class Book(BaseModel):
    id: int
    title: str
    author: str

class BookCreate(BaseModel):
    title: str
    author: str

# --- In-Memory Database ---
fake_users_db: dict[str, dict] = {}

fake_books_db: list[dict] = [
    {"id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald"},
    {"id": 2, "title": "To Kill a Mockingbird", "author": "Harper Lee"},
]

book_id_counter = 3

# --- Auth Utilities ---
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

app = FastAPI(title="Secure Book Library 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(db: dict, username: str, password: str) -> UserInDB | bool:
    user = get_user(db, username)
    if not user:
        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(fake_users_db, username=token_data.username)
    if user is None:
        raise credentials_exception
    return user

# --- Auth Endpoints ---
@app.post("/register", response_model=User)
async def register_user(user_data: UserCreate):
    if user_data.username in fake_users_db:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Username already registered",
        )
    hashed = get_password_hash(user_data.password)
    user_dict = {
        "username": user_data.username,
        "email": user_data.email,
        "role": "user",
        "disabled": False,
        "hashed_password": hashed,
    }
    fake_users_db[user_data.username] = user_dict
    return User(**user_dict)

@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, "role": user.role},
        expires_delta=access_token_expires,
    )
    return Token(access_token=access_token, token_type="bearer")

# --- Book 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=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )

@app.post("/books", response_model=Book, status_code=status.HTTP_201_CREATED)
async def create_book(book_data: BookCreate):
    global book_id_counter
    new_book = {"id": book_id_counter, "title": book_data.title, "author": book_data.author}
    fake_books_db.append(new_book)
    book_id_counter += 1
    return new_book

@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book(book_id: int):
    for i, book in enumerate(fake_books_db):
        if book["id"] == book_id:
            fake_books_db.pop(i)
            return
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )

Your API now issues JWT tokens and can verify them. In the next step, you'll wire get_current_user into your book endpoints so only authenticated users can access them, and add admin-only protection for creating and deleting books.

Protect Endpoints with Role-Based Access

You have a working authentication system that issues JWT tokens. But there is a gap: the book endpoints are still completely open.

Anyone can create or delete books without logging in. In this step, you will lock down every book endpoint so it requires a valid token, then add an extra layer that restricts dangerous operations to admin users only.

In this step, get ready to:
  • Add the get_current_user dependency to the book endpoints so they require authentication.
  • Create a get_current_admin_user dependency that checks the user's role and raises 403 if not admin.
  • Test the full protected flow in Swagger UI.
Add authentication to the book read endpoints

FastAPI's dependency injection system lets you attach security checks to any endpoint by adding a parameter. When you add get_current_user as a dependency, FastAPI will automatically extract and validate the JWT before the endpoint logic runs.

  • In main.py, scroll down to the # --- Book Endpoints --- section.
  • Find the get_books function. It currently looks like this:
@app.get("/books", response_model=list[Book])
async def get_books():
    return fake_books_db
  • Replace the entire get_books function with this updated version that requires a valid token:
@app.get("/books", response_model=list[Book])
async def get_books(
    current_user: Annotated[UserInDB, Depends(get_current_user)],
):
    return fake_books_db

What does this change do?

The current_user parameter uses Depends(get_current_user) to tell FastAPI: before running this endpoint, decode the JWT from the request header and give me the authenticated user.

If the token is missing or invalid, FastAPI returns a 401 response automatically. The endpoint code never runs.

  • Now find the get_book function directly below. It currently looks like this:
@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=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )
  • Replace the entire get_book function with this version:
@app.get("/books/{book_id}", response_model=Book)
async def get_book(
    book_id: int,
    current_user: Annotated[UserInDB, Depends(get_current_user)],
):
    for book in fake_books_db:
        if book["id"] == book_id:
            return book
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )
  • Save main.py.
  • Switch to the Swagger UI at http://127.0.0.1:8000/docs and try the GET /books endpoint. You should see a 401 response because no token was provided.

Still getting a 200 response without a token?

Make sure you saved main.py after your changes. The dev server auto-reloads, but only after you save.

Double-check that the current_user parameter is inside the function signature (between the parentheses), not after the colon.

Help me figure out why my endpoint isn't requiring authentication.

Create the admin-only dependency

Requiring a valid token is a good start, but some actions should be restricted further. Only admin users should be able to create or delete books. You will build a second dependency that layers on top of get_current_user and checks the user's role.

  • In main.py, find the get_current_user function (in the # --- Auth Utilities --- section).
  • Add the following new function directly below get_current_user, before the # --- Auth Endpoints --- comment:
async def get_current_admin_user(
    current_user: Annotated[UserInDB, Depends(get_current_user)],
) -> UserInDB:
    if current_user.role != "admin":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Not enough permissions",
        )
    return current_user

What does this code do?

This dependency chains on top of get_current_user. FastAPI runs get_current_user first to validate the token, then passes the authenticated user into get_current_admin_user.

If the user's role field is not "admin", it raises an HTTP 403 Forbidden error. This is different from 401 (not authenticated). A 403 means you are authenticated but you lack permission for this action.

  • Save main.py.

Why 403 instead of 401?

HTTP 401 means "who are you?" and HTTP 403 means "I know who you are, but you can't do this." Using the correct status code helps API consumers distinguish between a missing/expired token and an insufficient role.

Apply admin protection and test the full flow

Now you will apply the get_current_admin_user dependency to the write endpoints so only admins can create or delete books.

  • In main.py, find the create_book function. It currently looks like this:
@app.post("/books", response_model=Book, status_code=status.HTTP_201_CREATED)
async def create_book(book_data: BookCreate):
    global book_id_counter
    new_book = {"id": book_id_counter, "title": book_data.title, "author": book_data.author}
    fake_books_db.append(new_book)
    book_id_counter += 1
    return new_book
  • Replace the entire create_book function with this admin-protected version:
@app.post("/books", response_model=Book, status_code=status.HTTP_201_CREATED)
async def create_book(
    book_data: BookCreate,
    current_user: Annotated[UserInDB, Depends(get_current_admin_user)],
):
    global book_id_counter
    new_book = {"id": book_id_counter, "title": book_data.title, "author": book_data.author}
    fake_books_db.append(new_book)
    book_id_counter += 1
    return new_book
  • Now find the delete_book function. It currently looks like this:
@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book(book_id: int):
    for i, book in enumerate(fake_books_db):
        if book["id"] == book_id:
            fake_books_db.pop(i)
            return
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )
  • Replace the entire delete_book function with this admin-protected version:
@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book(
    book_id: int,
    current_user: Annotated[UserInDB, Depends(get_current_admin_user)],
):
    for i, book in enumerate(fake_books_db):
        if book["id"] == book_id:
            fake_books_db.pop(i)
            return
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )

What changed in these endpoints?

The only difference is Depends(get_current_admin_user) instead of Depends(get_current_user). FastAPI will first validate the token, then check the role. A regular user gets a 403 Forbidden response.

  • Save main.py.

Now test the full authentication and authorization flow in Swagger UI.

  • Switch to the Swagger UI at http://127.0.0.1:8000/docs.
  • Expand POST /register and click Try it out.
  • Enter the following JSON in the request body and click Execute:
{
  "username": "testuser",
  "email": "test@example.com",
  "password": "secret123"
}
  • You should see a 200 response with the new user's details (no password in the response).
  • Click the Authorize button at the top of the Swagger UI page.
  • Enter testuser in the username field and secret123 in the password field.
  • Click Authorize, then Close.
  • Try GET /books. You should see a 200 response with the list of books.
  • Try POST /books with any book data. You should see a 403 Forbidden response because testuser has the role "user", not "admin".

How do I test as an admin?

The registration endpoint always assigns the "user" role. To test admin access, you can temporarily change the "role": "user" line in the register_user function to "role": "admin", register a new user, then change it back. In a production app, admin roles would be assigned through a separate admin panel or database migration.

Getting 401 even after authorizing?

Make sure you registered the user first (POST /register) before trying to authorize. The Authorize button calls /token behind the scenes, which requires the user to exist.

Check that the username and password you entered in the Authorize dialog match exactly what you used during registration.

Help me figure out why I'm getting 401 after authorizing in Swagger UI.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py with all the changes from this step.

ⓧ I'd like to double check the full code

Here is the complete main.py at the end of this step:

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 pydantic import BaseModel
from pwdlib import PasswordHash

# --- Configuration ---
SECRET_KEY = "your-secret-key-here"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

# --- Password Hashing ---
password_hash = PasswordHash.recommended()

# --- Models ---
class Token(BaseModel):
    access_token: str
    token_type: str

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

class UserBase(BaseModel):
    username: str
    email: str
    role: str = "user"

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

class User(UserBase):
    disabled: bool = False

class UserInDB(User):
    hashed_password: str

class Book(BaseModel):
    id: int
    title: str
    author: str

class BookCreate(BaseModel):
    title: str
    author: str

# --- In-Memory Database ---
fake_users_db: dict[str, dict] = {}

fake_books_db: list[dict] = [
    {"id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald"},
    {"id": 2, "title": "To Kill a Mockingbird", "author": "Harper Lee"},
]

book_id_counter = 3

# --- Auth Utilities ---
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

app = FastAPI(title="Secure Book Library 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(db: dict, username: str, password: str) -> UserInDB | bool:
    user = get_user(db, username)
    if not user:
        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(fake_users_db, username=token_data.username)
    if user is None:
        raise credentials_exception
    return user


async def get_current_admin_user(
    current_user: Annotated[UserInDB, Depends(get_current_user)],
) -> UserInDB:
    if current_user.role != "admin":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Not enough permissions",
        )
    return current_user


# --- Auth Endpoints ---
@app.post("/register", response_model=User)
async def register_user(user_data: UserCreate):
    if user_data.username in fake_users_db:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Username already registered",
        )
    hashed = get_password_hash(user_data.password)
    user_dict = {
        "username": user_data.username,
        "email": user_data.email,
        "role": "user",
        "disabled": False,
        "hashed_password": hashed,
    }
    fake_users_db[user_data.username] = user_dict
    return User(**user_dict)


@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, "role": user.role},
        expires_delta=access_token_expires,
    )
    return Token(access_token=access_token, token_type="bearer")


# --- Book Endpoints ---
@app.get("/books", response_model=list[Book])
async def get_books(
    current_user: Annotated[UserInDB, Depends(get_current_user)],
):
    return fake_books_db


@app.get("/books/{book_id}", response_model=Book)
async def get_book(
    book_id: int,
    current_user: Annotated[UserInDB, Depends(get_current_user)],
):
    for book in fake_books_db:
        if book["id"] == book_id:
            return book
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )


@app.post("/books", response_model=Book, status_code=status.HTTP_201_CREATED)
async def create_book(
    book_data: BookCreate,
    current_user: Annotated[UserInDB, Depends(get_current_admin_user)],
):
    global book_id_counter
    new_book = {"id": book_id_counter, "title": book_data.title, "author": book_data.author}
    fake_books_db.append(new_book)
    book_id_counter += 1
    return new_book


@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book(
    book_id: int,
    current_user: Annotated[UserInDB, Depends(get_current_admin_user)],
):
    for i, book in enumerate(fake_books_db):
        if book["id"] == book_id:
            fake_books_db.pop(i)
            return
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Book not found",
    )

Your API is now fully secured. Every book endpoint requires a valid JWT token, and only admin users can create or delete books. You have built a production-pattern security system with authentication and role-based access control.

Secret mission

Add Token Refresh

Your access tokens work, but they last 30 minutes. In production, short-lived access tokens paired with long-lived refresh tokens let users stay logged in without re-entering credentials. Can you implement a refresh token system?

Clean Up Your Resources

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:

  • FastAPI dev server (running process in your terminal).
  • Project folder on your Desktop (contains main.py, your virtual environment, and all installed packages).

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with the API.

  • Your FastAPI dev server continues running in the terminal.
  • Your project folder and virtual environment stay on your Desktop.
  • You can access the Swagger UI at http://127.0.0.1:8000/docs anytime the server is running.

Pause - I'll come back to this later

Shut down the running server to free up your terminal. Your project files stay intact for next time.

  • Switch to the terminal where the FastAPI dev server is running.
  • Press Ctrl+C to stop the server.
  • Your project folder and virtual environment remain on your Desktop. When you come back, reactivate the virtual environment and run fastapi dev main.py again.

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes your code, virtual environment, and all installed packages.

  • Switch to the terminal where the FastAPI dev server is running.
  • Press Ctrl+C to stop the server.
  • Navigate to your Desktop and delete the project folder by running these commands:

macOS

cd ~/Desktop
ls

Windows

cd ~\Desktop
ls
  • Find your project folder name in the output, then delete it:

macOS

rm -rf [[PROJECT_FOLDER="your project folder name"]]

Windows

Remove-Item -Recurse -Force [[PROJECT_FOLDER="your project folder name"]]
  • Run ls again to confirm the folder is gone.

Nice Work!

Nice Work!

You just built a production-pattern secure REST API with FastAPI, JWT authentication, and role-based access control.

What you learned:

  • Built a REST API with FastAPI and automatic interactive Swagger documentation.
  • Implemented JWT-based authentication with the OAuth2 password flow and secured passwords with Argon2 hashing.
  • Created role-based access control using FastAPI's dependency injection to cleanly separate security logic from business logic.
  • Secret Mission: Added a token refresh system for seamless user sessions without re-entering credentials.

Ready to quiz yourself?