This tutorial implements proper authorization to protect routes and verify user permissions. The previous tutorial built a complete registration and login system with JSON Web Token generation and storage in local storage, added JavaScript for frontend authentication state management, and created a /me endpoint that validates tokens and returns the current user. However, none of this authentication is actually being used to protect anything yet.

The frontend still contains hard-coded user ID values of one throughout the codebase. Anyone can still create, edit, or delete any post because the system does not check who is making the request. The authentication infrastructure built in the previous tutorial provides no actual protection. This problem extends into the API as well—the PostCreate schema accepts a user ID in the request body, allowing post creation to be assigned to any user. The goal is to limit post creation to the currently authenticated user.

This tutorial represents the payoff where authentication finally protects routes. The implementation will create a reusable get current user dependency, remove the user ID from the PostCreate schema, replace all hard-coded frontend values with real authentication, add ownership checks so users can only edit or delete their own content, and build an account page for profile management.

Removing User ID from Post Creation Schema

Open schemas.py and examine the PostCreate schema. Currently, it includes a user ID field that comes from the request body:

class PostCreate(PostBase):
    user_id: int  # Temporary - will be removed

This design allows anyone to claim to be any user simply by sending a different user ID. The solution is to extract the user ID from the token instead of the request body. The token was issued by the server and can be trusted—the server knows who created each token and when.

Remove the user ID field from PostCreate:

class PostCreate(PostBase):
    pass

Now PostCreate inherits only from PostBase, which contains the title and content fields. The user ID is no longer part of what the client sends when creating a post. This is substantially more secure because clients cannot claim to be someone else—the server determines the user from the trusted token.

Creating a Reusable Authentication Dependencyauth

Open auth.py to create a dependency that combines token verification with database user lookup. The file already contains verify_access_token(), which returns the user ID if the token is valid, and oauth2_scheme, which extracts the token from the authorization header. The new dependency needs to combine these and look up the user from the database, returning the full user object.

This dependency differs from the /me endpoint. The /me endpoint is an API endpoint that users call directly. This dependency is used internally by other routes to require authentication.

Add the necessary imports:

from typing import Annotated
 
from fastapi import Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
 
import models
from database import get_db

Add the dependency function after verify_access_token():

async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],
    db: Annotated[AsyncSession, Depends(get_db)],
) -> models.User:
    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"},
        )
 
    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"},
        )
 
    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 user

The function takes a token from the authorization header (using the oauth2_scheme) and a database session. It verifies the token using verify_access_token() from the previous tutorial. If verification returns None, meaning the token is invalid or expired, it raises a 401 Unauthorized response. The function then converts the user ID to an integer—IDs are stored as strings in tokens. If conversion fails, it also raises a 401. Finally, it queries the database for the actual user object. If the user doesn’t exist (perhaps they were deleted), it raises another 401. If all checks pass, it returns the user object.

Any route that uses this dependency will automatically require a valid token and receive access to the full user object.

Create a type alias to make this dependency easier to use. Add this immediately after the get_current_user() function:

CurrentUser = Annotated[models.User, Depends(get_current_user)]

The Annotated type combines a base type with metadata. This creates a type alias called CurrentUser that represents a User object with the dependency metadata indicating it comes from get_current_user(). This pattern appears frequently in Pydantic—for example, you might annotate a string as Annotated[str, Field(max_length=50)] to indicate it must be 50 characters or fewer. This alias makes route signatures substantially cleaner. Instead of writing Annotated[models.User, Depends(get_current_user)] in every route, simply use CurrentUser.

Protecting Post Routes

Open routers/posts.py and import the new dependency:

from auth import CurrentUser

Protecting Post Creation

Locate the create_post function. Add the current user as a parameter:

@router.post("", response_model=PostCreate, status_code=status.HTTP_201_CREATED)
async def create_post(
    post: PostCreate,
    current_user: CurrentUser,
    db: Annotated[AsyncSession, Depends(get_db)],
):

Simply adding this parameter protects the route. If someone attempts to call this endpoint without a valid token, they receive a 401 Unauthorized response before the function even executes.

Delete the user verification block that checked if the user exists:

# DELETE THIS BLOCK:
result = await db.execute(select(models.User).where(models.User.id == post.user_id))
user = result.scalars().first()
if not user:
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="User not found",
    )

This code is no longer necessary—the current user already exists because the dependency verified it.

Update the post creation to use the authenticated user’s ID:

new_post = models.Post(
    title=post.title,
    content=post.content,
    user_id=current_user.id,  # From token, not request body
)

The post’s user ID now comes from the authenticated token instead of the request body. Notice that the code actually became shorter—approximately 10 lines were removed because manual user lookup is no longer needed.

Adding Ownership Checks to Updates

Users should not be able to edit posts they don’t own. Locate the update_post_full function and add the current user parameter:

@router.put("/{post_id}", response_model=PostResponse)
async def update_post_full(
    post_id: int,
    post_data: PostCreate,
    current_user: CurrentUser,
    db: Annotated[AsyncSession, Depends(get_db)],
):

Delete the user verification block that checked if the new user exists:

# DELETE THIS BLOCK:
if post_data.user_id != post.user_id:
    result = await db.execute(
        select(models.User).where(models.User.id == post_data.user_id),
    )
    user = result.scalars().first()
    if not user:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="User not found",
        )

Add an ownership check after verifying the post exists:

# Verify ownership
if post.user_id != current_user.id:
    raise HTTPException(
        status_code=status.HTTP_403_FORBIDDEN,
        detail="Not authorized to update this post",
    )

This checks whether the post belongs to the current user and raises a 403 Forbidden if not.

Why use 403 instead of 401? HTTP status codes have specific meanings. 401 Unauthorized indicates missing or invalid authentication—the user is not authenticated at all. 403 Forbidden indicates that the user is authenticated but lacks permission for this action. This distinction matters for API clients because it tells them whether they need to log in (401) or whether they are attempting something they are simply not allowed to do (403).

Remove the line that updates the user ID:

# DELETE THIS LINE:
post.user_id = post_data.user_id

The user ID should never change after post creation.

Protecting Partial Updates

The update_post_partial function follows the same pattern but is simpler. Add the current user parameter:

@router.patch("/{post_id}", response_model=PostResponse)  
async def update_post_partial(post_id: int, post_data: PostUpdate, current_user: CurrentUser, db: Annotated[AsyncSession, Depends(get_db)]):  
    result = await db.execute(select(models.Post).where(models.Post.id == post_id))  
    post = result.scalars().first()  
    if not post:  
        raise HTTPException(  
            status_code=status.HTTP_404_NOT_FOUND,  
            detail="Post not found",  
        )  
  
    if post.user_id != current_user.id:  
        raise HTTPException(  
            status_code=status.http_403_FORBIDDEN,  
            detail="Not authorized to update this post"  
        )
    ...

Protecting Post Deletion

Add protection to the delete_post function:

@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_post(
    post_id: int,
    current_user: CurrentUser,
    db: Annotated[AsyncSession, Depends(get_db)],
):
	...
	if post.user_id != current_user.id:  
	    raise HTTPException(  
	        status_code=status.http_403_FORBIDDEN,  
	        detail="Not authorized to update this post"  
	)
	...

Post routes now have substantially improved security. Users can only create posts as themselves, and they can only edit or delete their own content.

Protecting User Routes

Open routers/users.py and update the imports. Add CurrentUser:

from auth import CurrentUser

Remove oauth2_scheme and verify_access_token from the imports—these are no longer needed:

# REMOVE THESE:
# from auth import oauth2_scheme, verify_access_token

Simplifying the /me Endpoint

The reusable dependency demonstrates its power most clearly in the /me endpoint. Currently, this endpoint contains approximately 30 lines of code that manually extract the token, verify it, convert the user ID, handle errors, and query the database.

Replace the entire endpoint with:

@router.get("/me", response_model=UserPrivate)
async def get_current_user(current_user: CurrentUser):
    return current_user

Three lines of code replace the entire endpoint. The CurrentUser dependency performs all the work—extracting the token, verifying it, querying the database, and providing the current user. The endpoint simply returns the result.

This demonstrates the major benefit of reusable dependencies in FastAPI.

Protecting User Updates

Users should only be able to update their own profiles. Locate the update_user function and add the current user parameter:

@router.patch("/{user_id}", response_model=UserPrivate)
async def update_user(
    user_id: int,
    user_data: UserUpdate,
    current_user: CurrentUser,
    db: Annotated[AsyncSession, Depends(get_db)],
):

Add an ownership check at the very beginning of the function:

# Verify ownership
if user_id != current_user.id:
    raise HTTPException(
        status_code=status.HTTP_403_FORBIDDEN,
        detail="Not authorized to update this user",
    )

This checks immediately whether the user is attempting to update their own profile. If not, they receive a 403 Forbidden response. The rest of the function—user lookup, username and email validation—remains exactly the same.

Protecting User Deletion

Add protection to the delete_user function:

@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
    user_id: int,
    current_user: CurrentUser,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    # Verify ownership
    if user_id != current_user.id:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Not authorized to delete this user",
        )
    
    # Find the user
    result = await db.execute(select(models.User).where(models.User.id == user_id))
    user = result.scalars().first()
    
    if not user:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="User not found"
        )
    
    await db.delete(user)
    await db.commit()

These represent simple ownership rules, but the pattern demonstrates significant power in more complex applications. Dependencies could be extended to check admin roles, team memberships, or other authorization requirements.

Testing the API Backend

Before updating the frontend, verify that the API functions correctly. Debugging becomes confusing if the backend is broken when frontend changes are made.

Delete the current database to start fresh—the schema changes require a clean slate. Delete blog.db and restart the development server.

Testing Unauthenticated Access

Navigate to the API documentation at /docs. Attempt to create a post without authentication. Expand POST /api/posts and click “Try it out”. Notice that the post schema now contains only title and content—no user ID field.

Enter test values:

{
  "title": "Test Title",
  "content": "Test Content"
}

Execute the request. The response returns 401 Unauthorized with the detail “Not authenticated”. Route protection is working correctly.

Testing Authenticated Post Creation

Create a user first. Expand POST /api/users, click “Try it out”, and create a user:

{
  "username": "CoreyMS",
  "email": "corey@example.com",
  "password": "testpassword1!"
}

Execute the request. The user is created successfully with a 201 response.

Click the “Authorize” button in the top-right corner of the documentation. Enter the credentials (remember the “username” field expects the email address):

username: corey@example.com
password: testpassword1!

Click “Authorize”. The documentation interface is now authenticated.

Attempt to create a post again with the same test data. Execute the request. The response returns 201 Created with the complete post data:

Critically, only title and content were sent in the request. The user ID was not sent—it came from the token. The author information is correct, reflecting the currently logged-in user.

Testing Ownership Protection

Log out of the current user by clicking “Authorize” and selecting “Logout”. Create a second user:

{
  "username": "TestUser",
  "email": "testuser@gmail.com",
  "password": "testpassword1!"
}

Authorize with this new user:

username: testuser@gmail.com
password: testpassword1!

Attempt to edit the first user’s post. Expand PATCH /api/posts/{post_id}, enter post ID 1 (the first user’s post), and provide update data:

{
  "title": "Updated Title",
  "content": "Updated Content"
}

Execute the request. The response returns 403 Forbidden with the detail “Not authorized to update this post”:

The request was authenticated (the user is logged in), but they lack permission to update this post because they don’t own it. This is exactly correct behavior. 401 would indicate “not logged in”; 403 indicates “logged in but not permitted.”

The backend API is now working correctly with proper authentication and authorization.

Updating the Frontend

The frontend must send authorization headers with API requests and respect authentication state.

Updating the Navigation Bar

Open templates/layout.html and locate the logged-in navigation section. Replace it with a cleaner account-focused version:

<!-- 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>  
    <a class="btn btn-light mb-2 mb-md-0 me-md-3"  
       href="{{ url_for("account_page") }}"  
       id="accountBtn">Account</a>  
</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>

This removes the username display and logout button from the navbar and replaces them with a single account link. The logout functionality moves to the account page. The account button will display the username via JavaScript.

Updating the Authentication UI Script

Locate the authentication state management script in layout.html. Update the updateAuthUI() function:

<!-- Auth State Management -->  
<script type="module">  
    import { getCurrentUser } from '/static/js/auth.js';  
  
    async function updateAuthUI() {  
        const user = await getCurrentUser();  
        const loggedInNav = document.getElementById('loggedInNav');  
        const loggedOutNav = document.getElementById('loggedOutNav');  
        const accountBtn = document.getElementById('accountBtn');  
  
        if (user) {  
            loggedInNav.classList.remove('d-none');  
            loggedInNav.classList.add('d-flex');  
            loggedOutNav.classList.add('d-none');  
            accountBtn.textContent = user.username;  
        } else {  
            loggedInNav.classList.add('d-none');  
            loggedInNav.classList.remove('d-flex');  
            loggedOutNav.classList.remove('d-none');  
            accountBtn.textContent = 'Account';  
        }  
    }  
  
    updateAuthUI();  
</script>

Instead of setting a username display element, the function now updates the account button text to show the username. The logout handler is removed since that functionality moved to the account page.

A small flicker may be visible when the page loads—the navbar starts in the logged-out state and JavaScript updates it. The server doesn’t know who is logged in for template rendering because authentication relies on client-side tokens. Solutions exist for this problem, common in single-page applications, but addressing it falls outside the scope of this series.

Updating the Create Post Form

Import the getToken function at the top of the create post form script:

import { getToken } from '/static/js/auth.js';

This function already exists in auth.js from the previous tutorial.

Add a token check immediately after preventing the default form submission:

const token = getToken();
const token = getToken();  
if (!token) {  
    window.location.href = '/login';  
    return;  
}

If no token exists, redirect to login immediately. No point attempting to create a post without authentication.

Remove the hard-coded user ID:

// DELETE THIS:
// user_id: 1,

The server extracts the user from the token, so this field is no longer needed.

Add the authorization header to the fetch request:

// POST to our API as JSON  
const response = await fetch("/api/posts", {  
method: "POST",  
headers: {  
    "Content-Type": "application/json",  
    'Authorization': `Bearer ${token}`,  
},  
body: JSON.stringify(postData),  
});

This sends the JSON Web Token with the request. The server uses it to identify who is making the request.

Add a 401 check before the success check:

if (response.status === 401) {  
    window.location.href = '/login';  
    return;  
}  
  
if (response.ok) {  
const data = await response.json();  
document.getElementById("successMessage").textContent =  
    `Post "${data.title}" created successfully!`;

If the server returns a 401, the token is invalid or expired. Redirect to login.

Updating the Post Template

Open templates/post.html. The current template uses a Jinja2 conditional to show edit and delete buttons only when the post author matches a hard-coded user ID. This approach no longer works because the server doesn’t know who is logged in with the JWT approach—authentication state exists only in JavaScript.

Remove the Jinja2 conditional and add an ID and Bootstrap class to the buttons container:

<div id="postActions" class="post-actions mt-3 pt-3 border-top d-none">  
    <button type="button"  
            class="btn btn-outline-secondary me-1"  
            data-bs-toggle="modal"  
            data-bs-target="#editModal">Edit Post</button>  
    <button type="button"  
            class="btn btn-outline-danger"  
            data-bs-toggle="modal"  
            data-bs-target="#deleteModal">Delete Post</button>  
</div>

The d-none class hides the buttons by default. JavaScript will control visibility based on ownership.

Replace the entire script block at the bottom of the file:

<script type="module">  
import { getCurrentUser, getToken } from '/static/js/auth.js';  
import { getErrorMessage, showModal, hideModal } from '/static/js/utils.js';  
  
const postId = {{ post.id }};  
const postUserId = {{ post.user_id }};  
  
// Show edit/delete buttons only if current user owns this post  
async function checkOwnership() {  
    const user = await getCurrentUser();  
    if (user && user.id === postUserId) {  
        document.getElementById('postActions').classList.remove('d-none');  
    }  
}  
  
// Edit Post Form Handler  
const editForm = document.getElementById('editPostForm');  
editForm.addEventListener('submit', async (event) => {  
    event.preventDefault();  
  
    const token = getToken();  
    if (!token) { window.location.href = '/login'; return; }  
  
    const formData = new FormData(editForm);  
    const postData = Object.fromEntries(formData.entries());  
    delete postData.post_id;  
  
    try {  
        const response = await fetch(`/api/posts/${postId}`, {  
            method: 'PATCH',  
            headers: {  
                'Content-Type': 'application/json',  
                'Authorization': `Bearer ${token}`,  
            },  
            body: JSON.stringify(postData),  
        });  
  
        if (response.status === 401) { window.location.href = '/login'; return; }  
        if (response.status === 403) {  
            document.getElementById('errorMessage').textContent =  
                'You are not authorized to edit this post.';  
            hideModal('editModal');  
            showModal('errorModal');  
            return;  
        }  
  
        if (response.ok) {  
            document.getElementById('successMessage').textContent =  
                'Post updated successfully!';  
            hideModal('editModal');  
            showModal('successModal');  
  
            document.getElementById('successModal').addEventListener('hidden.bs.modal', () => {  
                window.location.reload();  
            }, { once: true });  
        } else {  
            const error = await response.json();  
            document.getElementById('errorMessage').textContent = getErrorMessage(error);  
            hideModal('editModal');  
            showModal('errorModal');  
        }  
    } catch (error) {  
        document.getElementById('errorMessage').textContent =  
            'Network error. Please check your connection and try again.';  
        showModal('errorModal');  
    }  
});  
  
// Delete Post Handler  
const deleteButton = document.getElementById('confirmDelete');  
deleteButton.addEventListener('click', async () => {  
    const token = getToken();  
    if (!token) { window.location.href = '/login'; return; }  
  
    try {  
        const response = await fetch(`/api/posts/${postId}`, {  
            method: 'DELETE',  
            headers: { 'Authorization': `Bearer ${token}` },  
        });  
  
        if (response.status === 401) { window.location.href = '/login'; return; }  
        if (response.status === 403) {  
            document.getElementById('errorMessage').textContent =  
                'You are not authorized to delete this post.';  
            hideModal('deleteModal');  
            showModal('errorModal');  
            return;  
        }  
  
        if (response.status === 204) {  
            window.location.href = '/';  
        } else {  
            const error = await response.json();  
            document.getElementById('errorMessage').textContent = getErrorMessage(error);  
            hideModal('deleteModal');  
            showModal('errorModal');  
        }  
    } catch (error) {  
        document.getElementById('errorMessage').textContent =  
            'Network error. Please check your connection and try again.';  
        showModal('errorModal');  
    }  
});  
  
checkOwnership();  
</script>

The script imports getCurrentUser and getToken from the authentication module. It stores the post owner’s ID from the template in a variable. The checkOwnership() function compares the current user to the post owner and shows the buttons if they match.

Both form handlers include token checks, authorization headers, and error handling for 401 (redirect to login) and 403 (show error message). The ownership check runs when the page loads.

Creating the Account Page

Adding the Route

Open main.py and add the account page route after the register route:

@app.get("/account", include_in_schema=False)
async def account_page(request: Request):
    return templates.TemplateResponse(request, "account.html", {"title": "Account"})

The include_in_schema=False parameter excludes this from the API documentation since it serves an HTML template rather than an API response.

Why not protect this route on the server like the API endpoints? The token is stored in localStorage, which is only accessible by JavaScript running in the browser. When someone navigates to /account, the browser makes a regular GET request. It doesn’t automatically include the token from localStorage—there is no mechanism for this. The server has no way to know if the user is logged in when rendering the page.

Instead, JavaScript handles this on the frontend. When the page loads, JavaScript checks if the user is logged in and redirects to the login page if not. This is purely a user experience convenience. It prevents non-logged-in users from seeing a broken page, but it is not real security. Someone could theoretically view the HTML by disabling JavaScript. The actual security comes from the API endpoints. Any attempt to update or delete the account would fail with a 401 because the API requires a valid token. Rely on the frontend for user experience, but always handle security on the backend.

Creating the Template

Create templates/account.html (code is not shown here because it’s too long).

The profile section displays the current user’s username, email, and profile picture (placeholder for the next tutorial). The update form allows users to modify their username and email. A logout button provides quick logout access. The danger zone includes a delete account button with confirmation modal.

The loadUserData() function fetches the current user from the /me endpoint and populates both the display and form fields. If no user exists, it redirects to login—this is the client-side guard.

The update form handler sends a PATCH request with the authorization header. On success, it clears the user cache (so the navbar refreshes with the new username) and reloads the page.

The logout button calls the logout() function from auth.js. The delete account handler sends a DELETE request, then clears the token and redirects to the homepage on success.

Testing the Complete Application

Delete the database again to start completely fresh and restart the server.

Navigate to the homepage. Click “Register” and create a new user:

Username: CoreyMS
Email: corey@example.com
Password: testpassword1!
Confirm Password: testpassword1!

After successful registration, the application redirects to the login page. Log in with the credentials. The login succeeds and redirects to the homepage. The navigation now shows a “New Post” button and an “Account” button displaying the username.

Click “New Post” and create a post:

Title: New Post
Content: New content

The post appears on the homepage with the correct author. Click the post to view it. The page displays edit and delete buttons because the current user owns this post.

Click “Edit” and update the post:

Title: Updated Post

The update succeeds and the page reloads with the new title.

Click the “Account” button in the navigation. The account page displays the current username, email, and profile information. Update the username:

Username: CoreyMSchafer

Click “Update Profile”. The profile updates successfully and the account button in the navigation immediately reflects the new username.

Log out and create a second user:

Username: TestUser
Email: test@example.com
Password: testpassword1!

Log in with this new user. Create two posts—one for general testing and one specifically for deletion testing:

Post 1:
Title: Test Post
Content: Test content

Post 2:
Title: Test Deletion
Content: Test deletion

Both posts appear on the homepage. Click the first user’s post (the “Updated Post” by Schafer). No edit or delete buttons appear because the current user (TestUser) does not own this post.

Click one of TestUser’s posts. Edit and delete buttons appear because TestUser owns this post. Click “Delete” and confirm. The post is deleted successfully.

Navigate to the account page. Click “Delete Account” at the bottom. Confirm the deletion in the modal. The account is deleted, the application logs out, and redirects to the homepage. All of TestUser’s posts have been removed from the homepage—only the first user’s post remains.

Summary

This tutorial implemented a complete authorization layer on top of the authentication system from the previous tutorial. The reusable get_current_user dependency protects routes and provides the authenticated user. All hard-coded user ID values have been removed from schemas and frontend code—everything now comes from the token instead. Ownership checks ensure users can only edit and delete their own content. All frontend code sends authorization headers with API requests.

The difference between HTTP status codes matters: 401 Unauthorized means “not authenticated” (missing or invalid token). 403 Forbidden means “authenticated but not permitted” (attempting an action without authorization). This distinction helps API clients understand whether they need to log in or whether they are trying something they cannot do.

Frontend protection versus backend security: The account page is not protected on the server because tokens are stored in localStorage, which only JavaScript can access. The server cannot see tokens in regular page navigation requests. JavaScript handles the redirect to login as a user experience convenience, but this is not security. Real security comes from the API endpoints, which require valid tokens for all state-changing operations.

The application now has real security in place. Users own their content. The hard-coded placeholders from earlier tutorials are gone. This represents how production applications function at a fundamental level. The next tutorial will cover file uploads and image processing to implement profile picture functionality, completing the profile management feature previewed on the account page.