This tutorial builds user registration and login functionality for the FastAPI application. Currently, the application uses a hard-coded user ID of one throughout, allowing anyone to create, edit, or delete any post without restrictions. This represents a fundamental security gap that must be addressed through proper authentication infrastructure.
The tutorial divides authentication and authorization into separate implementations. Authentication answers “who are you?” while authorization, covered in the next tutorial, answers “what are you allowed to do?” Both components are essential for a production application. This installment focuses exclusively on building the authentication layer.
Installing Authentication Dependencies
Three new packages provide the authentication infrastructure. The first is pwdlib with the argon2 extra for password hashing. Installation via pip requires:
uv add "pwdlib[argon2]"The brackets containing extras sometimes require quotes depending on the shell environment. While previous tutorials may have demonstrated bcrypt for password hashing, Argon2 represents the modern standard. It offers substantially greater resistance to GPU-based cracking attacks compared to bcrypt, making pwdlib with Argon2 the current best practice for password security.
The second package is PyJWT for creating and verifying JSON Web Tokens (JWTs):
uv add PyJWTPyJWT is the library recommended in the official FastAPI documentation. It provides a focused, straightforward API specifically designed for JSON Web Token operations. While older tutorials may reference alternative libraries, PyJWT has replaced those recommendations in recent FastAPI updates.
The third package is pydantic-settings for configuration management:
uv add pydantic-settingsPydantic Settings offers several advantages over alternatives like python-dotenv.
- First, it centralizes all configuration into a single settings module.
- Second, it validates types automatically using Pydantic’s validation system—if a setting expects an integer, the system ensures an integer is provided.
- Third, it fails fast with clear error messages when environment variables are missing or have incorrect types.
- Finally, it uses Pydantic’s
SecretStrtype to prevent accidental exposure of secrets in logs or print statements.
The package still loads values from .env files, so the development workflow remains unchanged. It simply provides a cleaner, more modern approach aligned with FastAPI conventions.
Database Preparation
Before implementing authentication features, the existing database must be removed. The user model will gain a required field, and SQLite does not easily support adding non-nullable columns to existing tables. In development environments, starting fresh is typically the simplest approach. Production systems would handle this through migrations using tools like Alembic, which will be covered later in the series. For now, delete the blog.db file to begin with a clean database.
Updating the User Model
The user model requires a new field to store password hashes. Open models.py and locate the User class. Currently, the model defines id, username, email, and image_file fields. The new field must be named password_hash, not password, because plain passwords must never be stored in databases. This naming convention enforces a critical security principle applicable to any authentication system, not just FastAPI applications.
Add the password hash field after the email field:
password_hash: Mapped[str] = mapped_column(String(200), nullable=False)The 200-character length accommodates Argon2 hashes, which produce variable-length strings. This limit provides ample space for any Argon2 output.
Updating Pydantic Schemas
The schemas module requires several modifications. Open schemas.py to implement these changes.
First, the UserCreate schema needs a password field to receive user input during registration:
class UserCreate(UserBase):
password: str = Field(min_length=8)This enforces an eight-character minimum password length, representing a reasonable security requirement for modern applications. More secure applications could add additional requirements such as character complexity rules.
Next, consider the privacy implications of the current UserResponse schema. It inherits from UserBase, which includes the email field. When someone views a post, should they see the author’s email address? Probably not. This represents both a privacy concern and a potential security issue—users generally do not want their email addresses publicly exposed.
Replace the single UserResponse schema with two distinct schemas. UserPublic handles public-facing data like post authorship, while UserPrivate returns complete information when users view their own data. Update the schemas as follows:
class UserPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
image_file: str | None
image_path: str
class UserPrivate(UserPublic):
email: EmailStrThe UserPublic schema contains id, username, and image information without exposing the email address. UserPrivate inherits from UserPublic and adds the email field. This ensures that post data never exposes user emails, significantly improving privacy protection.
Since UserResponse no longer exists, update PostResponse to use the new schema:
class PostResponse(PostBase):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
date_posted: datetime
author: UserPublicFinally, add a schema for login responses after the UserUpdate definition:
class Token(BaseModel):
access_token: str
token_type: strThis schema structures the response returned when users successfully authenticate.
Creating the Configuration Module
Pydantic Settings enables type-safe, validated configuration loading from environment variables. Create a new file named config.py in the project root:
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8"
)
secret_key: SecretStr
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
settings = Settings()The Settings class inherits from BaseSettings, which provides automatic environment variable loading. The model_config specifies that values should be loaded from a .env file with UTF-8 encoding.
Three settings define the authentication configuration. The secret_key uses SecretStr, a special Pydantic type that prevents accidental value exposure. If code accidentally prints the secret key, only asterisks appear instead of the actual value. Accessing the real string requires explicitly calling get_secret_value() on the key.
The algorithm defaults to HS256, the standard algorithm for JSON Web Tokens. The access_token_expire_minutes sets token validity to 30 minutes by default.
The final line creates a settings instance that loads all values when the module imports. This instance can be imported throughout the application to access configuration values.
How does Pydantic Settings map environment variables to fields? Field names match environment variable names in a case-insensitive manner. A field named secret_key maps to an environment variable named SECRET_KEY in the .env file. Pydantic Settings handles type conversions automatically—a string "60" in the .env file becomes an integer 60 in Python when the field type is declared as int.
The system follows a specific priority order when loading values. System environment variables take highest priority. If a variable is not set in the system environment, the .env file provides the value. If neither source contains the variable, the default value from the field definition is used (if one exists). This flexibility supports different deployment strategies—production deployments might use system environment variables while development uses a .env file.
Note that secret_key has no default value, making it required. The application will fail to start if this value is missing from either the system environment or the .env file.
Creating the Environment File
Create a .env file in the project root to store the secret key. In production applications, this key must be cryptographically secure. Generate a proper secret key using Python’s secrets module:
python -c "import secrets; print(secrets.token_hex(32))"This command generates a 64-character hexadecimal string suitable for production use. Add this value to .env:
SECRET_KEY=2b9e4eea2ec310e48281ce633ed7f0dc48c8ebe29439d602b137bb3763768d41
Critical security requirement: The .env file must never be committed to version control. Verify that .gitignore contains .env. Committing secrets to Git repositories represents one of the most common and serious security mistakes in software development. Once pushed to a remote repository like GitHub, secret keys become publicly visible, potentially exposing API credentials, database passwords, and authentication secrets. Ensure .env appears in .gitignore before making any commits.
One additional consideration: the env_file path in config.py is relative to the working directory where the application starts. Always run the server from the project root directory. Otherwise, Pydantic Settings cannot locate the .env file, resulting in an error about the missing secret_key.
Creating Authentication Utilities
Create a new file named auth.py in the project root. This module contains password hashing, token generation, and token verification functions.
Begin with the necessary imports:
from datetime import UTC, datetime, timedelta
import jwt
from fastapi.security import OAuth2PasswordBearer
from pwdlib import PasswordHash
from config import settingsInitialize the password hasher:
password_hash = PasswordHash.recommended()This creates a password hasher using Argon2 with recommended default settings.
Initialize the OAuth2 scheme:
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/users/token")The tokenUrl parameter must match the login endpoint path exactly. This scheme extracts bearer tokens from the Authorization header in incoming requests. A useful side effect: this configuration enables the “Authorize” button in the FastAPI documentation interface, simplifying authentication testing.
Create the password hashing function:
def hash_password(password: str) -> str:
return password_hash.hash(password)This function accepts a plain-text password and returns the hashed version for database storage.
Create the password verification function:
def verify_password(plain_password: str, hashed_password: str) -> bool:
return password_hash.verify(plain_password, hashed_password)This function compares a plain-text password against a stored hash, returning True if they match and False otherwise.
Why use hashing instead of encryption? Encryption is reversible—encrypted data can be decrypted back to its original form. Hashing is one-way and irreversible. Even if attackers steal the database, they cannot recover original passwords from hashes. Additionally, Argon2 generates a unique random salt for each hash, meaning the same password produces different hashes each time. This prevents attackers from precomputing hashes of common passwords (rainbow table attacks) and looking up matches in the stolen database.
Add the token creation function:
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
""" Create a JWT access token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(UTC) + expires_delta
else:
expire = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.secret_key.get_secret_value(), algorithm=settings.algorithm)
return encoded_jwtThis function creates a JSON Web Token from a data dictionary and an optional expiration time. It copies the input data to avoid modifying the original dictionary, calculates an expiration timestamp (defaulting to 15 minutes if not specified), adds the expiration to the payload, and encodes everything as a JWT using the secret key and algorithm from settings. Note the get_secret_value() call required to extract the actual secret string from the SecretStr type.
Add the token verification function:
def verify_access_token(token: str) -> str | None:
"""Verify a JWT access token and return the subject (user id) if valid."""
try:
payload = jwt.decode(token, settings.secret_key.get_secret_value(), algorithms=[settings.algorithm], options={"require": ["exp", "sub"]})
except jwt.InvalidTokenError:
return None
else:
return payload.get("sub")This function verifies a token and extracts the user ID from the sub (subject) claim. It decodes the token using the secret key and algorithm, extracts the user ID from the payload, and returns None if the token is invalid, expired, or missing the user ID.
JWT Structure: A JSON Web Token consists of three Base64-encoded parts separated by dots:
- The header contains the algorithm and token type.
- The payload contains the application data plus standard claims like expiration time.
- The signature proves the token was not tampered with—it is created using the secret key, meaning only the server can create valid tokens. Clients receive tokens but cannot forge them without knowing the secret key.
Updating User Routes
Open routers/users.py to implement registration and login endpoints. Add the required imports:
from datetime import timedelta
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import func
from auth import (
create_access_token,
hash_password,
oauth2_scheme,
verify_access_token,
verify_password,
)
from config import settings
from schemas import PostResponse, Token, UserCreate, UserPrivate, UserPublic, UserUpdateThe OAuth2PasswordRequestForm dependency handles login form parsing. The func import from SQLAlchemy enables case-insensitive database queries. The authentication utilities and schemas import the functions and types needed for the endpoints.
Updating User Creation
Modify the create_user endpoint to use UserPrivate as the response model and implement secure password handling. Update the response model:
@router.post("", response_model=UserPrivate, status_code=status.HTTP_201_CREATED)This ensures the user receives their complete information (including email) after registration.
Make the username and email uniqueness checks case-insensitive. Users should not be able to register both “CoreyMS” and “coreyms” as separate accounts:
# Check username
async def create_user(user: UserCreate, db: Annotated[AsyncSession, Depends(get_db)]):
result = await db.execute(select(models.User).where(func.lower(models.User.username) == user.username.lower()))
...
result = await db.execute(select(models.User).where(func.lower(models.User.email) == user.email.lower())) Update the user creation to hash the password and normalize the email:
new_user = models.User(
username=user.username, # Preserve original casing for display
email=user.email.lower(), # Store emails as lowercase
password_hash=hash_password(user.password), # Hash the password
)The username preserves its original casing for display purposes—if a user registers as “CoreyMS”, that formatting appears in the interface. Email addresses are stored as lowercase because emails are case-insensitive by design, making normalization sensible. The password must be hashed before storage—plain passwords never go into the database.
Implementing the Login Endpoint
Add the login endpoint immediately after user creation:
@router.post("/token", response_model=Token)
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[AsyncSession, Depends(get_db)],
):
# Look up user by email (case-insensitive)
# Note: OAuth2PasswordRequestForm uses "username" field, but we treat it as email result = await db.execute(
select(models.User).where(func.lower(models.User.email) == form_data.username.lower())
)
user = result.scalars().first()
# Verify user exists and password is correct
# Don't reveal which one failed (security best practice) if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create access token
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": str(user.id)},
expires_delta=access_token_expires
)
return Token(access_token=access_token, token_type="bearer")The OAuth2PasswordRequestForm dependency parses the login form data. Note a peculiarity of the OAuth2 specification: the form uses a field named username, but this implementation uses it for email authentication. This works because the OAuth2 spec defines the field name, not its semantic meaning. The form data’s username field simply holds the email address.
The endpoint looks up the user by email using a case-insensitive comparison, then verifies the password. An important security consideration: the error message is identical whether the user doesn’t exist or the password is wrong. Never reveal which piece of information was incorrect, as this would allow attackers to enumerate valid email addresses in the system.
If authentication succeeds, the endpoint creates an access token with the user’s ID stored in the sub (subject) claim, then returns the token with the bearer token type.
Adding the Current User Endpoint
Add a new endpoint at /me to retrieve the currently authenticated user. This endpoint must appear before the /{user_id} route because FastAPI matches routes in order. While the integer type hint on user_id would prevent matching, putting specific routes before parameterized ones represents best practice:
@router.get("/me", response_model=UserPrivate)
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
):
# Verify token and get user ID
user_id = verify_access_token(token)
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
# Convert user_id to integer with error handling
try:
user_id_int = int(user_id)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
# Look up user
result = await db.execute(select(models.User).where(models.User.id == user_id_int))
user = result.scalars().first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
return userThe oauth2_scheme dependency extracts the token from the Authorization header. The endpoint verifies the token and extracts the user ID, includes defensive error handling for the integer conversion, looks up the user in the database, and returns unauthorized if any step fails.
Why is this endpoint necessary? The frontend needs to know who is logged in. While JavaScript could decode the JWT locally, this would not validate whether the token is still valid. Calling this endpoint validates the token on the server (the authority on token validity) and retrieves complete user information.
Updating Remaining User Routes
Update the get_user endpoint to use UserPublic:
@router.get("/{user_id}", response_model=UserPublic)Update the update_user endpoint to use UserPrivate since only the authenticated user should update their own account:
@router.patch("/{user_id}", response_model=UserPrivate)This update endpoint also requires case-insensitive uniqueness checks. Update the username check:
if user_data.username and user_data.username.lower() != user.username.lower():
result = await db.execute(
select(models.User).where(func.lower(models.User.username) == user_data.username.lower())
)
existing = result.scalars().first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already exists"
)Update the email check:
if user_data.email and user_data.email.lower() != user.email.lower():
result = await db.execute(
select(models.User).where(func.lower(models.User.email) == user_data.email.lower())
)
existing = result.scalars().first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already exists"
)When actually updating the email, store it as lowercase:
if user_data.email is not None:
user.email = user_data.email.lower()Testing the API Endpoints
Before implementing the frontend, verify that the API functions correctly. Start the development server and navigate to the interactive documentation at /docs.
Testing User Creation
Expand the POST /api/users endpoint and click “Try it out”. Create a test user:
{
"username": "CoreyMS",
"email": "corey@example.com",
"password": "testpassword1!"
}Execute the request. A successful response returns status code 201 with the user data:
/attachments/Pasted-image-20260424113546.png)
Note that the password hash is never returned in the response—only stored securely in the database.
Testing Login
Expand the POST /api/users/token endpoint and click “Try it out”. The OAuth2 form has username and password fields:
/attachments/Pasted-image-20260424113706.png)
Despite the label, enter the email address in the username field:
username: corey@example.com
password: testpassword1!
A successful login returns the access token:
/attachments/Pasted-image-20260424122012.png)
Testing the Authorize Button
Click the “Authorize” button in the top-right corner of the documentation interface. Enter the email and password in the authentication dialog and click “Authorize”:
/attachments/Pasted-image-20260424122132.png)
The documentation interface is now authenticated:
/attachments/Pasted-image-20260424122202.png)
Test the GET /api/users/me endpoint by expanding it and clicking “Execute”. The response returns the current user’s information, proving the token is valid:
/attachments/Pasted-image-20260424122239.png)
Click “Authorize” again and select “Logout” to clear the token. Attempting to execute /me now returns a 401 Unauthorized response, confirming that authentication is required:
/attachments/Pasted-image-20260424122308.png)
Adding Frontend Routes
Open main.py and add template routes for login and registration pages. Add these before the exception handlers:
@app.get("/login", include_in_schema=False)
async def login_page(request: Request):
return templates.TemplateResponse(
request,
"login.html",
{"title": "Login"},
)
@app.get("/register", include_in_schema=False)
async def register_page(request: Request):
return templates.TemplateResponse(
request,
"register.html",
{"title": "Register"},
)The include_in_schema=False parameter excludes these routes from the API documentation since they serve HTML templates rather than API responses.
Creating the Registration Template
Create templates/register.html. The template includes a form with fields for username, email, password, and password confirmation.
The password fields enforce an eight-character minimum to match the schema validation. JavaScript handles form submission:
const registerForm = document.getElementById('registerForm');
const passwordInput = document.getElementById('password');
const confirmPasswordInput = document.getElementById('confirmPassword');
const passwordError = document.getElementById('passwordError');
// Check passwords match on input
confirmPasswordInput.addEventListener('input', () => {
if (passwordInput.value !== confirmPasswordInput.value) {
passwordError.classList.remove('d-none');
confirmPasswordInput.setCustomValidity('Passwords do not match');
} else {
passwordError.classList.add('d-none');
confirmPasswordInput.setCustomValidity('');
}
});The form also includes client-side password matching validation to provide immediate feedback before submitting to the API. This prevents unnecessary API calls when passwords obviously don’t match, improving user experience.
Creating the Login Template
Create templates/login.html. Note an OAuth2 specification quirk: the template labels the field “Email” but names it “username” because OAuth2PasswordRequestForm expects a field named username:
<form id="loginForm">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="username" required>
</div> <div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password"
class="form-control"
id="password"
name="password"
required>
</div> <button type="submit" class="btn btn-primary">Login</button>
</form>The JavaScript submits the form as form data (not JSON) because OAuth2PasswordRequestForm expects form-encoded data:
<script type="module">
import { getErrorMessage, showModal } from '/static/js/utils.js';
const loginForm = document.getElementById('loginForm');
loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(loginForm);
try {
// OAuth2PasswordRequestForm expects form data, not JSON
const response = await fetch('/api/users/token', {
method: 'POST',
body: formData,
});
if (response.ok) {
const data = await response.json();
// Store token in localStorage
localStorage.setItem('access_token', data.access_token);
// Show success modal and redirect to home
document.getElementById('successMessage').textContent =
'Login successful!';
showModal('successModal');
document.getElementById('successModal').addEventListener('hidden.bs.modal', () => {
window.location.href = '/';
}, { once: true });
} else {
const error = await response.json();
document.getElementById('errorMessage').textContent = getErrorMessage(error);
showModal('errorModal');
}
} catch (error) {
document.getElementById('errorMessage').textContent =
'Network error. Please check your connection and try again.';
showModal('errorModal');
}
});
</script>On successful login, the token is stored in localStorage and the user redirected to the homepage.
Why use localStorage for token storage? This approach works with all client types—web applications, mobile apps, CLI tools, and third-party integrations. It aligns with the bearer token standard, the common method for APIs to receive tokens in request headers.
Security consideration: localStorage is vulnerable to cross-site scripting (XSS) attacks where malicious scripts could read the token. Proper input sanitization prevents XSS attacks. Short token expiration times limit exposure if a token is stolen. For browser-only applications, HTTP-only cookies would be safer, but this application follows an API-first design intended to work with any client type, not just browsers.
Creating Client-Side Authentication State
Create static/js/auth.js to manage authentication state on the client:
let currentUser = null;
let fetchPromise = null;
export async function getCurrentUser() {
if (currentUser) {
return currentUser;
}
// Return in-progress fetch to prevent duplicate API calls
if (fetchPromise) {
return fetchPromise;
}
const token = localStorage.getItem("access_token");
if (!token) {
return null;
}
fetchPromise = (async () => {
try {
const response = await fetch("/api/users/me", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
currentUser = await response.json();
return currentUser;
}
localStorage.removeItem("access_token");
return null;
} catch (error) {
console.error("Error fetching current user:", error);
return null;
} finally {
fetchPromise = null;
}
})();
return fetchPromise;
}
export function logout() {
localStorage.removeItem("access_token");
currentUser = null;
window.location.href = "/";
}
export function getToken() {
return localStorage.getItem("access_token");
}
export function setToken(token) {
localStorage.setItem("access_token", token);
}
export function clearUserCache() {
currentUser = null;
}This module implements caching to avoid redundant API calls. If the user data is already cached, it returns immediately. If a fetch is already in progress, it returns that same promise instead of starting duplicate requests—important because multiple page components might call getCurrentUser() simultaneously.
The module fetches from /api/users/me with the bearer token. If the token is invalid or expired, it clears the token from storage, ensuring users see the logged-out state. If the response succeeds, it caches and returns the user data.
Why fetch user data instead of decoding the JWT in JavaScript? Calling the /me endpoint validates that the token is still valid on the server, which is the authority on token validity. It also provides complete user information beyond what’s stored in the token payload.
Updating the Layout Template
Open templates/layout.html and update the navigation bar to reflect authentication state. Replace the right side of the navbar with two conditional sections:
<!-- Shown when logged in (hidden by default, shown via JS) -->
<div id="loggedInNav" class="d-none">
<button class="btn btn-outline-light mb-2 mb-md-0 me-md-2"
type="button"
data-bs-toggle="modal"
data-bs-target="#createPostModal">New Post</button>
<span id="usernameDisplay" class="navbar-text me-md-2"></span>
<button class="btn btn-outline-light mb-2 mb-md-0 me-md-3"
type="button"
id="logoutBtn">Logout</button>
</div>
<!-- Shown when logged out -->
<div id="loggedOutNav">
<a class="btn btn-outline-light mb-2 mb-md-0 me-md-2"
href="{{ url_for("login_page") }}">Login</a>
<a class="btn btn-light mb-2 mb-md-0 me-md-3"
href="{{ url_for("register_page") }}">Register</a>
</div>The logged-in section includes the new post button, displays the user’s email, and provides a logout button. The logged-out section shows login and register links.
Add authentication state management at the end of the layout template:
<!-- Auth State Management -->
<script type="module">
import { getCurrentUser, logout } from '/static/js/auth.js';
// Update navbar based on auth state
async function updateAuthUI() {
const user = await getCurrentUser();
const loggedInNav = document.getElementById('loggedInNav');
const loggedOutNav = document.getElementById('loggedOutNav');
if (user) {
loggedInNav.classList.remove('d-none');
loggedInNav.classList.add('d-flex');
loggedOutNav.classList.add('d-none');
document.getElementById('usernameDisplay').textContent = user.email;
} else {
loggedInNav.classList.add('d-none');
loggedInNav.classList.remove('d-flex');
loggedOutNav.classList.remove('d-none');
}
}
// Logout handler
document.getElementById('logoutBtn').addEventListener('click', logout);
// Update UI on page load
updateAuthUI();
</script>The function checks for a current user, displays the appropriate navigation section, and populates the email display. Bootstrap utility classes like d-none and d-flex control element visibility much more cleanly than inline styles.
Testing the Complete Flow
Navigate to the homepage. Click “Register” and create a new user:
/attachments/Pasted-image-20260424142655.png)
The password matching validation ensures both password fields match before allowing submission. After successful registration, the application redirects to the login page.
Log in with the credentials. Upon successful login, the application redirects to the homepage with the navigation bar now showing the user’s email, the new post button, and the logout button.
Open the browser’s developer tools and navigate to Application → Local Storage. The access_token key contains the JWT:
/attachments/Pasted-image-20260424142857.png)
Click logout. The token disappears from local storage and the navigation reverts to showing login and register links:
/attachments/Pasted-image-20260424142916.png)
Summary
This tutorial implemented a complete authentication system for the FastAPI application. The backend now includes secure password hashing with Argon2, JSON Web Token generation and verification, registration and login endpoints, and a /me endpoint for retrieving the current user. The frontend provides registration and login forms, token storage in localStorage, and a UI that reflects authentication state.
Critical security points: Plain passwords never appear in the database—only Argon2 hashes are stored. Tokens have limited validity (30 minutes by default). Uniqueness checks for usernames and emails are case-insensitive to prevent similar-looking duplicates. Error messages never reveal whether a user exists, protecting against account enumeration. The .env file never goes into version control.
What remains unprotected: API endpoints still allow anyone to create, edit, and delete posts. Templates still show edit and delete buttons based on hard-coded user ID values. All hard-coded user_id = 1 values remain in the code. Anyone can access any route without restriction.
This is intentional. Authentication answers “who are you?” but does not yet answer “what are you allowed to do?” The next tutorial implements authorization—protecting routes with authentication dependencies, removing the user ID from the post creation schema, replacing all hard-coded user ID values with the actual authenticated user, and adding ownership checks for edit and delete operations. All these pieces will finally come together into a properly secured application.