NIXX/DEVv1.14.0
ArticlesFavorites
Sign In
Sign In
Articles

Welcome to our blog

A curated collection of insightful articles, practical guides, and expert tips designed to simplify your workflow

Cover image for: React Authentication with JWT: A Step-by-Step Guide
October 17, 20257 MIN READ min readBy ℵi✗✗

React Authentication with JWT: A Step-by-Step Guide

Learn how to implement secure JWT authentication in React. From login to route protection and API calls, this guide covers everything you need to know.

reactNode.jsauthenticationjwt
ℵi✗✗

ℵi✗✗

Full-Stack Developer

Passionate about building tools and sharing knowledge with the developer community.

Was this helpful?

Popular Posts

  • NixOS vs. Arch Linux: Which One Belongs in Your Dev Setup?

    NixOS vs. Arch Linux: Which One Belongs in Your Dev Setup?

    5 MIN READ min read

  • How to Enable HTTPS on Localhost in Under 2 Minutes

    How to Enable HTTPS on Localhost in Under 2 Minutes

    3 MIN READ min read

  • Migrating from Create React App (CRA) to Vite: A Step-by-Step Guide

    Migrating from Create React App (CRA) to Vite: A Step-by-Step Guide

    4 MIN READ min read

  • Array Destructuring in PHP: A Practical Guide for Modern Developers

    Array Destructuring in PHP: A Practical Guide for Modern Developers

    5 MIN READ min read

Recommended Products

  • Apple iPad (7th Gen)

    Apple iPad (7th Gen)

    4.3
  • Fitbit Versa 4

    Fitbit Versa 4

    4.3
  • JBL Flip 6

    JBL Flip 6

    4.8
  • Dell 24 Monitor — SE2425HM Full HD

    Dell 24 Monitor — SE2425HM Full HD

    4.7

May contain affiliate links

Topics

webdev33productivity16cybersecurity12javascript11automation9guide8react7typescript7php6tutorial6freelancing5github actions5privacy5how to4Node.js4
+111 more topics →
🇺🇸USD ACCOUNTOpen a free US-based USD accountReceive & save in USD — powered by ClevaSponsoredInterserver Hosting#1 VALUEAffordable, reliable hosting from $2.50/mo99.9% uptimeSponsored

JSON Web Tokens are a widely used approach to stateless authentication. The server issues a signed token when the user logs in, the client stores it and sends it with subsequent requests, and the server verifies the signature before processing each request. There is no session store on the server to query — the token itself carries the authentication state.

This guide builds a complete JWT authentication flow in React: a login form, token storage, protected routes, authenticated API requests, and the security considerations that matter for production use.

What this covers:

  • How JWTs are structured and verified

  • A Node.js backend that issues tokens on login

  • A React login form that stores the token

  • Protected routes with React Router

  • Sending authenticated requests

  • Token storage security: localStorage vs httpOnly cookies

  • Token expiry, refresh tokens, and logout


How JWT Works

A JSON Web Token has three parts, separated by dots:

header.payload.signature

The header specifies the token type and signing algorithm. The payload contains claims — data about the user, such as their ID and roles. The signature is a hash of the header and payload signed with a secret key. The server verifies the signature on each request to confirm the token has not been tampered with.

A decoded payload might look like:

{
  "userId": 1,
  "role": "admin",
  "iat": 1714000000,
  "exp": 1714003600
}

iat is the issue time. exp is the expiry time. The server rejects tokens where exp is in the past.

The token is not encrypted by default — the payload is base64-encoded and readable by anyone who has the token. Do not put sensitive data in the payload. The security guarantee is the signature: without the server's secret key, the payload cannot be modified without invalidating the signature.


Step 1: Backend — Issue Tokens on Login

A minimal Express backend that issues JWTs:

// server.js
import express from "express";
import jwt from "jsonwebtoken";
import cors from "cors";

const app = express();
app.use(express.json());
app.use(cors({ origin: "http://localhost:3000", credentials: true }));

const SECRET = process.env.JWT_SECRET; // never hardcode in production

app.post("/login", (req, res) => {
  const { username, password } = req.body;

  // Replace with a real database check
  if (username === "admin" && password === "password") {
    const token = jwt.sign(
      { userId: 1, username },
      SECRET,
      { expiresIn: "15m" }
    );

    // Option A: return in response body (client stores in memory or localStorage)
    return res.json({ token });

    // Option B: set as httpOnly cookie (more secure for web apps)
    // res.cookie("token", token, {
    //     httpOnly: true,
    //     secure: process.env.NODE_ENV === "production",
    //     sameSite: "strict",
    //     maxAge: 15 * 60 * 1000,
    // });
    // return res.json({ success: true });
  }

  res.status(401).json({ message: "Invalid credentials" });
});

app.listen(5000);

Two delivery options are shown and commented. The response body approach is simpler to implement. The httpOnly cookie approach is more secure for web applications because JavaScript cannot access httpOnly cookies, which prevents XSS attacks from stealing the token. The tradeoff is more complex logout handling and CORS configuration.

The expiresIn: "15m" is intentionally short. Short-lived access tokens limit the window of exposure if a token is stolen. Pair this with refresh tokens (covered below) to maintain sessions without requiring frequent logins.


Step 2: React Login Form

// Login.jsx
import { useState } from "react";

function Login({ onLoginSuccess }) {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError("");

    try {
      const res = await fetch("http://localhost:5000/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username, password }),
        credentials: "include", // required if using cookies
      });

      if (!res.ok) {
        const data = await res.json();
        setError(data.message || "Login failed");
        return;
      }

      const data = await res.json();
      if (data.token) {
        // Store in memory (most secure) or localStorage (convenient but XSS-vulnerable)
        sessionStorage.setItem("token", data.token);
      }
      onLoginSuccess();
    } catch {
      setError("Network error. Please try again.");
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <p role="alert">{error}</p>}
      <input
        type="text"
        placeholder="Username"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
        required
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
      />
      <button type="submit">Log in</button>
    </form>
  );
}

export default Login;

Two improvements over the minimal version most guides show: error state is displayed in the UI rather than via alert(), and failed fetch responses are handled separately from network errors.


Step 3: Protected Routes

A ProtectedRoute component redirects unauthenticated users to the login page:

// ProtectedRoute.jsx
import { Navigate, useLocation } from "react-router-dom";

function ProtectedRoute({ children }) {
  const token = sessionStorage.getItem("token");
  const location = useLocation();

  if (!token) {
    // Pass the attempted location so login can redirect back after success
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return children;
}

export default ProtectedRoute;

Passing state={{ from: location }} to the redirect allows the login page to redirect the user back to the page they were trying to reach after a successful login:

// In Login.jsx
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/dashboard";

// After successful login:
navigate(from, { replace: true });

Router setup:

// App.jsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Login from "./Login";
import Dashboard from "./Dashboard";
import Settings from "./Settings";
import ProtectedRoute from "./ProtectedRoute";

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/login" element={<Login />} />
        <Route
          path="/dashboard"
          element={
            <ProtectedRoute>
              <Dashboard />
            </ProtectedRoute>
          }
        />
        <Route
          path="/settings"
          element={
            <ProtectedRoute>
              <Settings />
            </ProtectedRoute>
          }
        />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

Step 4: Sending Authenticated Requests

Include the token in the Authorization header for API requests:

async function fetchProtectedData(endpoint) {
  const token = sessionStorage.getItem("token");

  const res = await fetch(`http://localhost:5000${endpoint}`, {
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    credentials: "include",
  });

  if (res.status === 401) {
    // Token is invalid or expired — redirect to login
    sessionStorage.removeItem("token");
    window.location.href = "/login";
    return;
  }

  return res.json();
}

Handling 401 responses explicitly prevents the application from silently failing when a token expires. Clearing the stored token and redirecting to login gives the user a clear path to re-authenticate.

On the server, the middleware that verifies the token:

function authenticateToken(req, res, next) {
  const authHeader = req.headers["authorization"];
  const token = authHeader?.split(" ")[1];

  if (!token) return res.sendStatus(401);

  jwt.verify(token, SECRET, (err, decoded) => {
    if (err) {
      // err.name === "TokenExpiredError" for expired tokens
      return res.sendStatus(err.name === "TokenExpiredError" ? 401 : 403);
    }
    req.user = decoded;
    next();
  });
}

app.get("/protected", authenticateToken, (req, res) => {
  res.json({ userId: req.user.userId });
});

Distinguishing TokenExpiredError from other verification errors (403) allows the client to handle expired tokens differently from invalid tokens — for example, attempting a token refresh before redirecting to login.


Token Storage Security

The choice of where to store JWTs has security implications:

  • localStorage / sessionStorage: Accessible via JavaScript, which means an XSS vulnerability can steal the token. localStorage persists across browser sessions. sessionStorage is cleared when the tab closes.

  • httpOnly cookies: Not accessible via JavaScript. Protected from XSS token theft. Requires CSRF protection (use SameSite: Strict or SameSite: Lax, or implement a CSRF token). More complex to set up but more secure for production web applications.

  • In-memory (React state): The most secure option against XSS because the token is not persisted anywhere. The tradeoff is that the token is lost on page refresh, requiring a refresh token flow or re-authentication.

For most production web applications, httpOnly cookies are the recommended approach. For server-to-server or mobile contexts, the Authorization header with a token stored in secure storage is appropriate.


Token Expiry and Refresh Tokens

Short access token expiry (15 minutes) limits exposure but requires a mechanism to extend sessions without requiring the user to log in repeatedly.

The standard approach is a refresh token: a long-lived token stored in an httpOnly cookie that can be exchanged for a new access token. The access token is short-lived and can be stored less securely. The refresh token is long-lived but only ever travels to the refresh endpoint.

// Refresh endpoint
app.post("/refresh", (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) return res.sendStatus(401);

  jwt.verify(refreshToken, REFRESH_SECRET, (err, decoded) => {
    if (err) return res.sendStatus(403);

    const newAccessToken = jwt.sign(
      { userId: decoded.userId },
      SECRET,
      { expiresIn: "15m" }
    );
    res.json({ token: newAccessToken });
  });
});

On the client, intercept 401 responses and attempt a refresh before redirecting to login:

async function fetchWithRefresh(endpoint) {
  let res = await fetch(`http://localhost:5000${endpoint}`, {
    headers: { Authorization: `Bearer ${getAccessToken()}` },
    credentials: "include",
  });

  if (res.status === 401) {
    // Attempt token refresh
    const refreshRes = await fetch("http://localhost:5000/refresh", {
      method: "POST",
      credentials: "include",
    });

    if (refreshRes.ok) {
      const { token } = await refreshRes.json();
      storeAccessToken(token);

      // Retry the original request
      res = await fetch(`http://localhost:5000${endpoint}`, {
        headers: { Authorization: `Bearer ${token}` },
        credentials: "include",
      });
    } else {
      window.location.href = "/login";
      return;
    }
  }

  return res.json();
}

Key Takeaways

  • JWTs are not encrypted by default. The payload is readable by anyone with the token. Do not store sensitive data in the payload.

  • Use short access token expiry (15 minutes) and refresh tokens for production applications. Long-lived access tokens are a security liability.

  • httpOnly cookies are more secure than localStorage for web applications. JavaScript cannot read httpOnly cookies, which prevents XSS from stealing tokens.

  • The ProtectedRoute component should pass the attempted location to the login redirect so users are returned to the right page after authentication.

  • Handle 401 responses explicitly in API calls. Silently failing when a token expires produces confusing application behavior.

  • Distinguish TokenExpiredError from other JWT verification errors on the server. Expired tokens warrant a refresh attempt; invalid tokens warrant a 403 and re-authentication.


Conclusion

JWT authentication in React follows a consistent pattern: the backend issues a signed token on login, the client stores and sends it with requests, and the server verifies it before responding. The implementation details — token storage, expiry, refresh flow, and protected route handling — determine how secure and maintainable the setup is.

The code here is a working foundation. For production, httpOnly cookies, a refresh token flow, and robust 401 handling are the additions that make the authentication layer genuinely secure.


Building JWT auth with a specific framework or backend? Share your setup or a problem you ran into in the comments.

Topics
reactNode.jsauthenticationjwt
Interserver Hosting#1 VALUEAffordable, reliable hosting from $2.50/mo99.9% uptimeSponsored

Discussion

Join the discussion

Sign in to share your thoughts and engage with the community.

Sign In
Loading comments…

Continue Reading

More Articles

View all
Cover image for: Array Destructuring in PHP: A Practical Guide for Modern Developers
Mar 12, 20255 MIN READ min read

Array Destructuring in PHP: A Practical Guide for Modern Developers

From PHP 7.1 to 8.1—learn how array destructuring simplifies variable assignment, reduces boilerplate, and improves readability in modern PHP development.

Cover image for: The 3-Device Rule: How to Simplify Your Digital Life and Stop Overbuying Tech
Aug 5, 20255 MIN READ min read

The 3-Device Rule: How to Simplify Your Digital Life and Stop Overbuying Tech

Tired of juggling too many devices? Learn the 3-device rule that helps you streamline your digital life, reduce clutter, and focus on what really matters.

Cover image for: AI for DevOps: Tools That Are Already Changing the Game
Jun 17, 20256 MIN READ min read

AI for DevOps: Tools That Are Already Changing the Game

How artificial intelligence is transforming CI/CD pipelines, monitoring, and incident response—today.

Cover image for: Why You Should Use TypeScript in Every JavaScript Project
Jul 23, 20255 MIN READ min read

Why You Should Use TypeScript in Every JavaScript Project

JavaScript gets the job done—but TypeScript helps you write cleaner, safer, and easier-to-maintain code. Here’s why it’s worth using everywhere.

|Made with · © 2026|TermsPrivacy
AboutBlogContact

Free, open-source tools for developers and creators · Community driven