LearnContact
Lesson 5724 min read

Authentication Basics

Learn the general flow of authenticating users in a React single-page app, including token storage, a shared AuthContext, and protecting routes.

Introduction

Almost every real-world application needs to know who is using it. Authentication is the process of verifying a user's identity — typically with a username and password — and then remembering that they are "logged in" as they move around the app. In a React single-page app, this involves coordinating a login form, a backend API, and shared state that the whole app can read.

This lesson walks through the general shape of that flow: how credentials become a token, where that token lives in the browser, how a Context provider makes the current user available anywhere, and how to stop unauthenticated visitors from reaching pages they should not see.

What You Will Learn
  • The typical login flow: credentials in, token back, token attached to future requests.
  • The tradeoffs between storing a token in memory versus localStorage.
  • How to build a simple AuthContext that shares the current user and login/logout functions app-wide.
  • How to protect routes with a ProtectedRoute wrapper that redirects unauthenticated users.

The Authentication Flow

Most token-based authentication follows the same basic shape, regardless of the exact backend technology involved.

A Typical Login Flow
User submits login form (email + password)
        ↓
React sends credentials to the backend (e.g. POST /api/login)
        ↓
Backend verifies credentials and returns a token
        ↓
React stores the token client-side
        ↓
React attaches the token to future API requests
        ↓
Backend checks the token on each request to identify the user

Login Form

Collects email/username and password, then submits them to the backend.

Token Response

The backend replies with a token (often a JWT) representing the authenticated session.

Client Storage

The token is kept somewhere in the browser so it survives between requests (and possibly page reloads).

Authenticated Requests

The token is attached to future requests, usually in an Authorization header, so the backend knows who is asking.

Attaching a token to a request
async function fetchProfile(token) {
  const response = await fetch("/api/profile", {
    headers: {
      Authorization: `Bearer \${token}`,
    },
  });

  if (!response.ok) {
    throw new Error("Failed to load profile");
  }

  return response.json();
}

Where to Store the Token

Once the backend returns a token, React needs to keep it somewhere accessible. The two common choices are in-memory (a React state variable, lost on refresh) and localStorage (persisted across page reloads, but readable by any script on the page).

In Memory (React state)

Token lives only in a variable, e.g. inside AuthContext state.

  • Not accessible to other scripts after the fact — safer against XSS reading it.
  • Lost on page refresh, so the user must log in again unless combined with a refresh-token flow.

localStorage

Token is written to the browser's localStorage.

  • Survives page refreshes — the user stays logged in.
  • Readable by any JavaScript running on the page, which is a risk if the app is vulnerable to XSS.
There Is No Perfect Answer

Production apps often combine approaches — for example, a short-lived token in memory plus an HTTP-only cookie for refreshing it — but that is beyond this lesson's scope. For learning purposes, localStorage is simple and common; just be aware it is not the most secure option available.

Building an AuthContext

Rather than passing the current user and login/logout functions down through props everywhere, authentication state is a great fit for Context — it is genuinely "global" information that many unrelated components need.

AuthContext.jsx
import { createContext, useContext, useState } from "react";

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [token, setToken] = useState(() => localStorage.getItem("token"));

  async function login(email, password) {
    const response = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) {
      throw new Error("Invalid email or password");
    }

    const data = await response.json();
    setUser(data.user);
    setToken(data.token);
    localStorage.setItem("token", data.token);
  }

  function logout() {
    setUser(null);
    setToken(null);
    localStorage.removeItem("token");
  }

  const value = { user, token, login, logout };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  return useContext(AuthContext);
}

Wrapping the app in `<AuthProvider>` once, near the root, makes `useAuth()` available to any component that needs to know who is logged in or trigger a login/logout.

Example: Login and Logout

With the context in place, a login form becomes a thin wrapper around `useAuth().login`, and any component can show the current user and a logout button.

LoginForm.jsx
import { useState } from "react";
import { useAuth } from "./AuthContext";

function LoginForm() {
  const { login } = useAuth();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  async function handleSubmit(event) {
    event.preventDefault();
    try {
      await login(email, password);
    } catch (err) {
      setError(err.message);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
      />
      <button type="submit">Log In</button>
      {error && <p className="field-error">{error}</p>}
    </form>
  );
}

export default LoginForm;
UserMenu.jsx
import { useAuth } from "./AuthContext";

function UserMenu() {
  const { user, logout } = useAuth();

  if (!user) return null;

  return (
    <div>
      <span>Welcome, {user.name}</span>
      <button onClick={logout}>Log Out</button>
    </div>
  );
}

export default UserMenu;
After a successful login
Welcome, Rahul
[Log Out]

Protecting Routes

Some pages — a dashboard, an account settings screen — should only be reachable by logged-in users. A common pattern is a `ProtectedRoute` component that checks the auth state and redirects to the login page if there is no user.

ProtectedRoute.jsx
import { Navigate } from "react-router-dom";
import { useAuth } from "./AuthContext";

function ProtectedRoute({ children }) {
  const { user } = useAuth();

  if (!user) {
    return <Navigate to="/login" replace />;
  }

  return children;
}

export default ProtectedRoute;
Using ProtectedRoute in a router
<Routes>
  <Route path="/login" element={<LoginForm />} />
  <Route
    path="/dashboard"
    element={
      <ProtectedRoute>
        <Dashboard />
      </ProtectedRoute>
    }
  />
</Routes>
One Wrapper, Many Routes

Because ProtectedRoute just checks context and renders its children (or redirects), the same wrapper can guard any number of routes without repeating the login check everywhere.

A Security Note

This Is a Teaching Example, Not Production-Ready Auth

The pattern in this lesson demonstrates the shape of client-side authentication, but real production systems need much more: HTTPS everywhere, secure and HTTP-only cookies or refresh-token rotation, protection against XSS and CSRF, rate limiting on login attempts, and careful backend validation of every token. Never treat this lesson's code as ready to deploy as-is — treat it as the mental model for how the pieces fit together.

Common Mistakes

Avoid These Mistakes
  • Trusting the client to enforce access control — the backend must always verify the token on every protected request, not just the frontend UI.
  • Storing highly sensitive tokens in localStorage without understanding the XSS tradeoff.
  • Forgetting to clear the token on logout, leaving stale credentials behind.
  • Redirecting to login inside every component manually instead of centralizing the check in a ProtectedRoute.
  • Assuming a hidden route is a secure route — hiding a link in the UI does not stop someone from visiting the URL directly.

Best Practices

  • Centralize authentication state in a single AuthContext rather than duplicating it across components.
  • Keep the token-storage decision explicit and documented — know why you chose memory vs localStorage.
  • Always re-verify authentication and authorization on the backend for every protected request.
  • Use a single ProtectedRoute wrapper to guard multiple routes consistently.
  • Treat any hand-rolled auth code as a starting point for learning, and reach for a battle-tested provider (e.g. Auth0, Firebase Auth, Clerk) for real production apps.

Frequently Asked Questions

What is a JWT?

A JSON Web Token — a compact, signed string that encodes claims about a user (like their ID) so the backend can verify a request came from an authenticated session without a database lookup on every request.

Is localStorage safe for tokens?

It is common and simple, but it is readable by any JavaScript on the page, so it is vulnerable if your app has an XSS bug. More security-conscious setups use HTTP-only cookies, which JavaScript cannot read at all.

Why does ProtectedRoute use react-router-dom's Navigate?

Navigate performs a declarative redirect within React Router — rendering it tells the router to change the current route, which is the idiomatic way to redirect inside a component render.

Should login state also be checked on the backend?

Yes, always. The ProtectedRoute pattern only improves the frontend experience by hiding pages from unauthenticated users; the backend must independently verify the token on every API call regardless of what the frontend shows.

Do I need Redux or another state library for auth?

Not necessarily. Context plus useState, as shown here, is enough for many apps. Larger apps sometimes combine Context with a data-fetching library for caching and refreshing the current user.

Key Takeaways

  • Token-based authentication follows a consistent flow: credentials in, token back, token attached to future requests.
  • Storing a token in memory versus localStorage is a real security-vs-persistence tradeoff.
  • An AuthContext shares the current user and login/logout functions across the whole app.
  • A ProtectedRoute wrapper centralizes redirect logic for pages that require authentication.
  • This lesson's pattern is a simplified teaching example — production auth needs far more hardening.

Summary

Authentication in a React SPA is mostly about coordinating three things: a login request that exchanges credentials for a token, a place to store that token, and a way to share the resulting "who is logged in" state across the app. Context makes the last part easy, and a small wrapper component handles route protection.

In this lesson, you walked through the login flow, built an AuthContext with login and logout, and wrote a ProtectedRoute to guard pages. Next, you will turn to making apps like this fast, covering how to recognize and fix unnecessary re-renders.

Lesson 57 Completed
  • You understand the general token-based login flow.
  • You can weigh the tradeoffs between in-memory and localStorage token storage.
  • You can build an AuthContext exposing the current user and login/logout functions.
  • You can write a ProtectedRoute to guard authenticated-only pages.
Next Lesson →

Performance Optimization