🔐 Authentication Basics (JWT) & FastAPI Security

Authentication is not just a login page. 🙅‍♀️

It is about understanding how a backend identifies a user, verifies their identity, issues a token, and protects private API endpoints.

In this guide we'll learn everything from absolute zero:

  • ✅ How JWT works internally
  • ✅ What OAuth2PasswordBearer actually does
  • ✅ Why password hashing is necessary
  • ✅ How Depends() makes authentication reusable
  • ✅ How all these pieces connect in a real React + FastAPI + PostgreSQL project (my Social Feed app)

Simple examples, real-world analogies, Python code and execution flows. No assumed knowledge. Let's go! 🚀

📚 Table of Contents

  1. Authentication vs Authorization
  2. Why do we need authentication?
  3. Token-based authentication
  4. What is JWT?
  5. JWT issuing and verification
  6. PyJWT and python-jose
  7. What is OAuth2?
  8. What is OAuth2PasswordBearer?
  9. What is Depends()?
  10. Password hashing (and why plaintext is a crime)
  11. Passlib, bcrypt and Argon2id
  12. Building the complete FastAPI auth system
  13. Full execution flow
  14. Expiry, access tokens, refresh tokens and logout
  15. JWT vs session-based authentication
  16. HTTP status codes
  17. Common security mistakes
  18. Architecture of a real FastAPI project
  19. Interview questions
  20. Quick quiz and final revision

1️⃣ Authentication vs Authorization

These two words sound similar, but they mean different things.

Imagine Instagram 📸. You open it and enter:

  • Email: deepika@gmail.com
  • Password: mypassword123

Instagram checks whether these credentials belong to a registered user. That's Authentication.

Now you're logged in and you try to edit someone else's profile. Instagram checks whether you have permission to do that. That's Authorization.

Concept Meaning Example
🪪 Authentication Who are you? Verify your login
🚦 Authorization What can you do? Can you delete this post?

🧠 Remember: Authentication = identity verification. Authorization = permission checking. Authentication usually happens first, authorization follows.

2️⃣ Why do we need authentication?

Suppose you build a social media app using:

  • ⚛️ React frontend
  • ⚡ FastAPI backend
  • 🐘 PostgreSQL database

Your API has these endpoints:

GET /feed POST /posts DELETE /posts/15 GET /users/10 

What if anyone can call DELETE /posts/15? 😱 Anyone could delete anyone's post!

The backend needs a way to answer:

  1. Who is making this request?
  2. Are they logged in?
  3. Is their identity valid?
  4. Are they allowed to do this operation?

That's exactly what authentication (and authorization) solves.

3️⃣ What is token-based authentication?

A token is a piece of data that represents a user's authenticated session or identity.

Think of a movie theatre 🎬:

  1. You buy a ticket.
  2. The ticket is given to you.
  3. You show it at the entrance.
  4. Staff verifies it.
  5. You're allowed inside.

You don't buy a new ticket for every section of the theatre. Same idea here:

  1. User enters email and password.
  2. Backend verifies the credentials.
  3. Backend generates a token.
  4. Frontend stores the token.
  5. Frontend sends the token with every later request.
  6. Backend verifies the token before allowing access.

After a successful login the backend returns something like:

{ "access_token": "eyJhbGciOiJIUzI1NiJ9.example.signature", "token_type": "bearer" } 

And the frontend sends it back on future requests:

GET /feed Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.example.signature 

3.1 🏷️ What is the Authorization header?

HTTP requests can carry headers, extra information about the request.

GET /profile Authorization: Bearer abc123 Content-Type: application/json 
  • Authorization → the header name
  • Bearer → tells the server "I'm giving you a bearer token"
  • abc123 → the token itself

A bearer token basically means: whoever holds this valid token can use it. 🎟️

⚠️ That's why tokens must be protected. If someone steals your token, they may be able to act as you.

3.2 🔄 The complete authentication flow

  1. User logs in: React sends email and password to POST /login.
  2. Backend verifies credentials: FastAPI fetches the user from PostgreSQL and checks the password hash.
  3. Backend creates a JWT: it creates and signs a token containing claims.
  4. Frontend receives the token: the response contains access_token and token_type.
  5. Frontend makes another request: React sends Authorization: Bearer <token>.
  6. FastAPI verifies the token: it checks the signature, expiry and claims.
  7. Protected endpoint runs: if valid, the backend identifies the user and continues.

Now let's understand JWT itself. 👇

4️⃣ What is JWT (JSON Web Token)?

JWT stands for JSON Web Token. It's a compact format for transferring information between parties as a JSON-based, signed token.

A JWT looks like this:

xxxxx.yyyyy.zzzzz 

Three parts separated by dots:

HEADER . PAYLOAD . SIGNATURE 
Part Contains
🧾 Header Algorithm and token type
📦 Payload User identity and token claims
✍️ Signature Cryptographic proof of integrity

4.1 🧾 Header

{ "alg": "HS256", "typ": "JWT" } 
  • alg → algorithm used to sign the token
  • HS256 → HMAC using SHA-256
  • typ → token type (JWT)

4.2 📦 Payload (claims)

Claims are statements about the user or the token.

{ "sub": "101", "role": "user", "iat": 1794398200, "exp": 1794400000 } 
Claim Meaning
sub Subject, usually the user identifier
exp Expiration time
iat Issued-at time
iss Issuer of the token
aud Intended audience
jti Unique token identifier

These are standard registered claims. You can also add custom claims like role.

🚨 Very important: a JWT payload is NOT encrypted by default. Anyone holding the token can decode the header and payload!

So NEVER put these inside a JWT:

  • ❌ Passwords
  • ❌ Password hashes
  • ❌ Secret API keys
  • ❌ Sensitive personal information

A signed JWT gives you integrity, not confidentiality.

4.3 ✍️ Signature (the security-critical part)

The backend signs the token using a secret key. Conceptually:

Signature = HMAC-SHA256( encoded_header + "." + encoded_payload, SECRET_KEY ) 

Imagine an attacker changes the payload from "sub": "user_101" to "sub": "admin_999". Without the secret key they cannot produce a matching signature, so verification fails. 🛡️

Encoding vs signing vs encryption:

  • 📝 Encoding → makes data representable in a transport format
  • ✍️ Signing → verifies integrity and authenticity
  • 🔒 Encryption → hides data from unauthorized readers

JWTs using HS256 are signed, not encrypted.

5️⃣ JWT issuing and verification

🏭 Issuing

Creating and signing a JWT, usually right after a successful login.

token = create_access_token(user_id=101) 

The function receives the identity, builds the payload, adds expiry, signs it and returns the JWT string.

🔍 Verification

Happens whenever a client sends the token to a protected API:

  1. Extract the token
  2. Verify the signature
  3. Check the expiration
  4. Read the user ID from the payload
  5. Identify the corresponding user
  6. Allow or reject the request

A token can be rejected because:

  • ❌ Invalid signature
  • ❌ Expired
  • ❌ Malformed structure
  • ❌ Required claims missing
  • ❌ The user or session is no longer valid

💡 A valid signature does not automatically mean the account is still active. You may also need to check the user or session in your database.

6️⃣ PyJWT and python-jose

You never implement signing algorithms by hand. You use a library. 📦

Library Purpose
PyJWT Create, encode, decode and verify JWTs
python-jose Create, encode, decode and verify JWTs

Both work with FastAPI. Older tutorials (and older FastAPI docs) use python-jose, while the more actively maintained PyJWT is what the current FastAPI documentation uses. I'll use PyJWT in this article:

pip install pyjwt 

If you'd rather use python-jose:

pip install "python-jose[cryptography]" 

(The function names encode / decode are almost identical, so everything below translates easily.)

6.1 🏭 Creating a JWT

import jwt from datetime import datetime, timedelta, timezone SECRET_KEY = "my-super-secret-key" # ⚠️ demo only! ALGORITHM = "HS256" def create_access_token(user_id: int) -> str: expire = datetime.now(timezone.utc) + timedelta(minutes=30) payload = { "sub": str(user_id), "exp": expire, } return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) 

Line-by-line:

  • SECRET_KEY: the secret used to sign the JWT. In real apps, never hardcode it. Load it from an environment variable. Make it strong, random and private. 🔑
  • ALGORITHM = "HS256": HMAC SHA-256. The same secret signs and verifies.
  • expire: the token dies 30 minutes from now. If a token is stolen but expires soon, the damage window is small. ⏳
  • "sub": str(user_id): by convention sub is a string, so we convert the ID.
  • jwt.encode(...): encodes the header and payload, creates the signature, joins the three parts and returns the JWT string. ✨

That's JWT issuing!

6.2 🔍 Verifying a JWT

import jwt from jwt import InvalidTokenError def verify_access_token(token: str): try: payload = jwt.decode( token, SECRET_KEY, algorithms=[ALGORITHM], # 👈 explicit allow-list! ) except InvalidTokenError: return None user_id = payload.get("sub") if user_id is None: return None return user_id 

What jwt.decode() does:

  • Decodes the token
  • Checks the signature
  • Validates exp (it raises an error if the token has expired)
  • Rejects invalid tokens

⚠️ Notice algorithms=[ALGORITHM]. Always specify the algorithms you accept. Never blindly trust the algorithm declared by an incoming token.

7️⃣ What is OAuth2?

You might be thinking: "We learned JWT, why OAuth2? Aren't they the same?"

NOOO! 🙅 They're different things.

  • OAuth2 → an authorization framework. It describes how authorization is granted and how tokens are obtained.
  • JWT → a token format. It describes how a token can be represented.

OAuth2 doesn't require JWT. OAuth2 access tokens can also be opaque random strings.

7.1 🌍 Real-world example

You see a "Continue with Google" button and click it. Google authenticates you and your app receives authorization information through an OAuth-based flow. You never give your Google password to the third-party app. 🎉

One nuance:

  • OAuth2 primarily handles authorization.
  • OpenID Connect (OIDC), built on top of OAuth2, adds an identity layer for authentication. Google Sign-In generally uses OIDC.

7.2 ⚡ OAuth2 in FastAPI

FastAPI ships security utilities for common OAuth2-compatible patterns. The most important one is OAuth2PasswordBearer. 👇

8️⃣ What is OAuth2PasswordBearer?

OAuth2PasswordBearer is a FastAPI security dependency. Its job:

  1. 📍 Tell FastAPI where the token comes from
  2. 📤 Extract the bearer token from the Authorization header
  3. 📥 Hand that token to your dependency or endpoint
  4. 📖 Describe the OAuth2 security scheme in the OpenAPI docs (that green Authorize button in Swagger UI!)
from fastapi.security import OAuth2PasswordBearer oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") 

What does tokenUrl="login" mean?

It tells FastAPI (and the docs UI) that the endpoint used to obtain a token is POST /login.

What does it actually do?

Given this request:

GET /profile Authorization: Bearer my-secret-token 

it extracts my-secret-token (it strips the Bearer prefix).

It does NOT:

  • ❌ Generate the JWT
  • ❌ Verify the JWT signature
  • ❌ Check the user's password
  • ❌ Fetch the user from PostgreSQL
  • ❌ Log the user in

🧠 OAuth2PasswordBearer only extracts the token. You still have to verify it yourself.

8.1 What if the token is missing?

If a request has no Authorization header, OAuth2PasswordBearer automatically responds with HTTP 401 Unauthorized. 🚫

9️⃣ What is Depends()?

Before building the full system we need dependency injection, another core FastAPI concept.

Imagine ten protected endpoints:

GET /profile GET /feed POST /posts DELETE /posts/10 POST /comments GET /notifications ... 

Every one needs authentication. Would you copy-paste the same token-checking code ten times? 😵 No way. FastAPI gives us:

Depends() 

It means: "FastAPI, before running this endpoint, run this dependency and give me its result."

from fastapi import Depends, FastAPI app = FastAPI() def verify_user(): return "Deepika" @app.get("/profile") def get_profile(user=Depends(verify_user)): return {"user": user} 

What happens:

  1. A request hits /profile
  2. FastAPI sees Depends(verify_user)
  3. FastAPI runs verify_user()
  4. It returns "Deepika"
  5. FastAPI passes that value into the user parameter
  6. The endpoint runs

Response:

{ "user": "Deepika" } 

That's Dependency Injection (DI). 💉

🔗 Connect the dots

  • Depends() → the dependency injection mechanism
  • OAuth2PasswordBearer → a dependency that extracts a bearer token
  • Your custom dependency (get_current_user) → verifies the token and identifies the user

Combining them

from fastapi import Depends from fastapi.security import OAuth2PasswordBearer oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") @app.get("/profile") def get_profile(token: str = Depends(oauth2_scheme)): return {"received_token": token} 
Incoming request Authorization: Bearer abc123 │ ▼ Depends(oauth2_scheme) │ extracts "abc123" ▼ Endpoint receives token = "abc123" 

But we haven't verified that abc123 is a valid JWT yet. That's coming up! 😉

🔟 Password hashing: why we can't store passwords directly

This is a HUGE real-world security concept. 🚨

A user registers with:

Email: deepika@gmail.com Password: deepika123 

A beginner might store this directly:

id email password
1 deepika@gmail.com deepika123

If your database is ever compromised, the attacker reads everyone's passwords. 💀 Applications must NEVER store plaintext passwords. They store password hashes.

10.1 What is hashing?

Hashing is a one-way transformation of input into a fixed-format output.

Input: deepika123 Hash: $argon2id$v=19$m=65536,t=3,p=4$... 

(The exact output depends on the algorithm and a random salt.)

You cannot practically reverse a properly designed password hash back to the original password.

Hashing Encryption
One-way Reversible with the key
No decryption operation Can be decrypted
Used for password verification Used for confidentiality
Example: Argon2id Example: AES

Password hashing is deliberately designed to make guessing expensive. 💸

10.2 What is bcrypt?

bcrypt is a password-hashing algorithm built specifically for password storage. Its key property is a configurable computational cost: the higher the cost, the more resources each hash needs, making bulk guessing slower.

10.3 🧂 What is a salt?

A salt is a random value added during password hashing.

Two users register with the same password:

User A: deepika123 User B: deepika123 

Without salts, a deterministic scheme gives both the same hash. With unique random salts, their stored hashes differ.

Why it matters:

  • ✅ Identical passwords get different hashes
  • ✅ Precomputed rainbow-table attacks become much less useful
  • ✅ Bulk password guessing gets harder

Good libraries generate and manage salts automatically. Don't roll your own.

10.4 ✅ How does verification work?

You do not "decrypt" the stored hash. Instead:

  1. The library reads the algorithm and salt from the stored hash
  2. Hashes the submitted password with those parameters
  3. Compares the results securely
  4. Returns True or False
REGISTRATION LOGIN plain password entered password │ │ ▼ ▼ hashing verify against stored hash │ │ ▼ ▼ store the hash True / False 

1️⃣1️⃣ What is Passlib?

Passlib is a Python library for password hashing and verification. It supports bcrypt, Argon2, PBKDF2 and more, so you never implement hashing by hand.

pip install "passlib[argon2]" 

💡 I'm using Argon2id here, a modern, memory-hard algorithm (it needs both CPU and memory to compute, making guessing even more expensive). If you prefer bcrypt, use pip install "passlib[bcrypt]" and schemes=["bcrypt"]. Just make sure your passlib and bcrypt versions are compatible, since newer bcrypt releases have caused warnings or errors with older passlib.

from passlib.context import CryptContext pwd_context = CryptContext( schemes=["argon2"], deprecated="auto", ) 
  • CryptContext → Passlib's hashing configuration object
  • schemes=["argon2"] → our chosen algorithm
  • deprecated="auto" → lets Passlib flag older schemes that should be upgraded when users log in

Hash a password

def hash_password(password: str) -> str: return pwd_context.hash(password) hashed = hash_password("deepika123") print(hashed) # a long Argon2id hash, NOT the original password 

Verify a password

def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) print(verify_password("deepika123", hashed)) # True ✅ print(verify_password("wrongpassword", hashed)) # False ❌ 

🧠 Passlib is the library; Argon2id / bcrypt is the algorithm. Don't mix them up!

The important lesson isn't "always use bcrypt". It's use a modern, slow, salted password-hashing algorithm, never plaintext and never ordinary SHA-256.

1️⃣2️⃣ Building a complete FastAPI authentication system 🏗️

Time to combine everything. We'll build:

  1. ✅ User registration
  2. ✅ Password hashing
  3. ✅ User login
  4. ✅ Password verification
  5. ✅ JWT generation
  6. ✅ Token extraction
  7. ✅ Token verification
  8. ✅ Protected endpoints
  9. ✅ A reusable authentication dependency

We'll use an in-memory dictionary so it's beginner-friendly. In a real app, PostgreSQL and a repository layer replace it.

12.1 📁 Folder structure

backend/ │ ├── app/ │ ├── main.py │ │ │ ├── api/ │ │ └── auth.py │ │ │ ├── core/ │ │ └── security.py │ │ │ ├── dependencies/ │ │ └── auth.py │ │ │ ├── models/ │ │ └── user.py │ │ │ └── schemas/ │ └── auth.py │ └── requirements.txt 
File Responsibility
core/security.py Password hashing, JWT creation and verification
api/auth.py Login and registration endpoints
dependencies/auth.py Extract token, verify token, identify current user
models/user.py User database model
schemas/auth.py Request and response validation
main.py Creates the FastAPI application

This is separation of concerns: each file has one job. 🎯

Install what we need:

pip install fastapi uvicorn pyjwt "passlib[argon2]" python-multipart 

(python-multipart is required because the login endpoint receives form data.)

12.2 🔐 Step 1: core/security.py

import os from datetime import datetime, timedelta, timezone import jwt from jwt import InvalidTokenError from passlib.context import CryptContext # 🔑 Load from environment, NEVER hardcode in real projects SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "dev-only-change-me") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") # 🔒 Password helpers def hash_password(password: str) -> str: return pwd_context.hash(password) def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) # 🎟️ JWT helpers def create_access_token(user_id: int) -> str: now = datetime.now(timezone.utc) payload = { "sub": str(user_id), "iat": now, "exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), } return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) def decode_access_token(token: str) -> str | None: try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) except InvalidTokenError: return None return payload.get("sub") 

Four reusable operations in one place:

  • hash_password()
  • verify_password()
  • create_access_token()
  • decode_access_token()

12.3 🚪 Step 2: Registration and login routes (api/auth.py)

from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel from app.core.security import ( create_access_token, hash_password, verify_password, ) router = APIRouter() # 🧪 In-memory "database" (use PostgreSQL in real life!) users: dict[str, dict] = {} class RegisterRequest(BaseModel): email: str password: str @router.post("/register", status_code=status.HTTP_201_CREATED) def register(data: RegisterRequest): if data.email in users: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="User already exists", ) users[data.email] = { "id": len(users) + 1, "email": data.email, "hashed_password": hash_password(data.password), } return {"message": "User registered successfully"} @router.post("/login") def login(form_data: OAuth2PasswordRequestForm = Depends()): user = users.get(form_data.username) # username field = our email # Same error for "no such user" and "wrong password" 👇 if not user or not verify_password(form_data.password, user["hashed_password"Source:https://dev.to/deepika_pusala/authentication-basics-jwt-fastapi-security-a-beginner-friendly-guide-2nlc

Related posts