Most FastAPI auth tutorials stop at "it returns a token". This one goes further: one file, 118 lines, five endpoints, and every response below comes from actually running it, including the failure cases most guides skip: no token, wrong password, a tampered token and an expired one.
I build it line by line in this video:
Tested with: Python 3.12 · FastAPI 0.128 · PyJWT 2.15 · pwdlib 0.2 (Argon2) · Pydantic 2.13 · Uvicorn 0.39.
What you'll build
| Endpoint | What it does |
|---|---|
GET / |
Root message and a link to the docs |
GET /health |
Health check |
POST /token |
Log in with username and password and get a JWT |
GET /me |
Returns the logged-in user (token required) |
GET /protected |
Example of a guarded route (token required) |
The flow: the client sends a username and password to /token, gets back a signed JWT, then sends it as Authorization: Bearer <token> on every protected request. One dependency, get_current_user(), does all the checking, so protecting a new route is a single line.
1. Install
requirements.txt:
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
pyjwt>=2.9.0
pwdlib[argon2]>=0.2.0
python-multipart>=0.0.9
python-dotenv>=1.0.1
python3.12 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
- PyJWT signs and verifies tokens. (FastAPI's docs moved from python-jose to PyJWT.)
- pwdlib[argon2] hashes passwords with Argon2, the modern replacement for passlib.
- python-multipart is needed because the login endpoint reads an HTML-style form.
Use Python 3.10 or newer: the code uses str | None type hints, which Pydantic rejects on 3.9.
2. Config: secret, algorithm, expiry
load_dotenv()
app = FastAPI(title="FastAPI JWT Authentication", version="1.0.0")
JWT_SECRET = os.getenv("JWT_SECRET", "dev-only-secret-change-me")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
password_hash = PasswordHash.recommended()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
PasswordHash.recommended() picks Argon2. OAuth2PasswordBearer(tokenUrl="token") tells FastAPI where tokens come from. It also powers the Authorize button in Swagger UI.
Generate a real secret and put it in .env. More on why below:
python -c "import secrets; print(secrets.token_urlsafe(32))"
JWT_SECRET=paste-the-output-here
3. Users, models and password checks
For the demo, users live in a dict, and the password is hashed when the app starts, so no plain-text password is ever stored:
DEMO_USERS = {
"demo": {
"username": "demo",
"full_name": "Demo User",
"hashed_password": password_hash.hash("demo123"),
}
}
class Token(BaseModel):
access_token: str
token_type: str
class User(BaseModel):
username: str
full_name: str | None = None
def authenticate_user(username: str, password: str) -> User | None:
record = DEMO_USERS.get(username)
if record is None:
return None
if not password_hash.verify(password, record["hashed_password"]):
return None
return get_user(username)
Notice that User has no password field. Because the routes declare response_model=User, the hash can never leak into a response by accident.
4. Creating the token
def create_access_token(data: dict, expires: timedelta | None = None) -> str:
payload = data.copy()
if expires is None:
expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload["exp"] = datetime.now(timezone.utc) + expires
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
Use datetime.now(timezone.utc), not the deprecated utcnow(). PyJWT checks exp for you when it decodes.
5. The dependency that guards every route
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
except InvalidTokenError:
raise error
username = payload.get("sub")
if username is None:
raise error
user = get_user(username)
if user is None:
raise error
return user
Two details matter here:
-
algorithms=[ALGORITHM]is a list you pass explicitly. Never let the token's own header decide the algorithm. - Every failure returns the same 401. A bad signature, an expired token and an unknown user all look identical to the client, which gives attackers nothing to learn from.
6. The endpoints
@app.post("/token", response_model=Token)
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
user = authenticate_user(form.username, form.password)
if user is None:
raise HTTPException(status_code=401, detail="Incorrect username or password")
token = create_access_token({"sub": user.username})
return Token(access_token=token, token_type="bearer")
@app.get("/me", response_model=User)
async def read_me(user: Annotated[User, Depends(get_current_user)]):
return user
@app.get("/protected")
async def protected(user: Annotated[User, Depends(get_current_user)]):
return {
"message": f"Hello {user.username}, your JWT is valid.",
"framework": "FastAPI",
"language": "Python",
}
To protect any new route, add user: Annotated[User, Depends(get_current_user)] to its parameters. That's the whole pattern.
7. Run it
uvicorn app:app --reload
Open http://127.0.0.1:8000/docs, click Authorize, and log in with demo / demo123. Or test it with curl, as I did below.
8. Real test results: the happy path
Log in:
curl -X POST http://127.0.0.1:8000/token -d "username=demo&password=demo123"
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdW...","token_type":"bearer"}
A JWT is three base64url parts separated by dots: header, payload, signature. Decoding the first two:
{"alg":"HS256","typ":"JWT"}
{"sub":"demo","exp":1791785986}
The payload is readable by anyone. It's signed, not encrypted, so never put passwords or private data in it.
Use the token:
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/me
{"username":"demo","full_name":"Demo User"}
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/protected
{"message":"Hello demo, your JWT is valid.","framework":"FastAPI","language":"Python"}
9. Real test results: the failure cases
This is the part that tells you your auth actually works.
| Test | Status | Response |
|---|---|---|
/me with no token
|
401, www-authenticate: Bearer
|
{"detail":"Not authenticated"} |
| Login with a wrong password | 401 | {"detail":"Incorrect username or password"} |
/me with the last character of the token changed
|
401 | {"detail":"Could not validate credentials"} |
/me with a token that expired 1 minute ago
|
401 | {"detail":"Could not validate credentials"} |
Changing a single character breaks the signature, and the expired token is rejected by PyJWT's built-in exp check. Neither needed any extra code.
The warning PyJWT 2.15 gave me
When I signed a token with the fallback secret, PyJWT printed:
InsecureKeyLengthWarning: The HMAC key is 25 bytes long, which is below the
minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
"dev-only-secret-change-me" is only 25 bytes. For HS256 your secret should be at least 32 bytes. secrets.token_urlsafe(32) gives you 43 characters, which is plenty. Set JWT_SECRET in .env and the warning disappears.
Before you ship this
This is a learning foundation, not a finished security system:
- Move users into a real database, and hash passwords at signup, not at startup.
- Keep the secret out of the repo. Use a secrets manager in production, and rotate it.
- Add refresh tokens, so the 30-minute access token can stay short-lived.
-
Rate-limit
/tokento slow down password guessing. - Use HTTPS. A bearer token is as good as a password while it's valid.
- Keep the failure tests from section 9 in your test suite.
Full video
Every line is typed on screen, then tested live in Swagger UI: FastAPI JWT Authentication Tutorial (2026).
Stuck on an error? Paste the traceback in the comments and I'll take a look.
Drafted with AI assistance. The code is from my own project, and every response above comes from a real run I did on October 12, 2026.
Top comments (0)