Skip to main content

Command Palette

Search for a command to run...

How to Implement JWT Authentication in a React and Node.js App

Updated
10 min read
O
Full-stack engineer building real-world applications with Next.js and Vite. I share practical insights, projects, and lessons from my journey.

Introduction

Authentication is one of those things every web application needs but a lot of tutorials oversimplify it. In this article I'll show you how to implement JWT authentication the right way, covering both the backend and the frontend.

We'll build a working auth system with user registration, login, protected routes on the backend, token decoding on the frontend, and an expiry check that keeps your app secure. I'll also cover what OWASP says about handling tokens so you know what to avoid in production.

This is based on how I implemented auth in my own Taskflow application, so everything here is from real experience.

What is JWT?

JWT stands for JSON Web Token. It is a way to securely transmit information between a client and a server as a compact, self-contained string.

A JWT has three parts separated by dots:

header.payload.signature

The header contains the algorithm used to sign the token. The payload contains the data you put in the token, like the user's ID and when the token expires. The signature is used to verify that the token has not been tampered with.

Here is what a decoded JWT payload looks like:

{
  "id": 1,
  "email": "johndoe@example.com",
  "iat": 1713000000,
  "exp": 1713086400
}

iat is when the token was issued and exp is when it expires. Never put sensitive information like passwords in the payload because the payload is only base64 encoded, not encrypted. Anyone can decode it.

How It Works in Practice

Here is the full flow we are going to build:

  1. User registers with email and password

  2. Password gets hashed with bcrypt and stored in the database

  3. User logs in with their credentials

  4. Backend verifies the password, signs a JWT, and sends it back

  5. Frontend stores the token and decodes it to get user info

  6. On every protected request, the frontend sends the token in the Authorization header

  7. Backend middleware verifies the token before allowing access to the route

Setting Up the Project

We will build on top of the Users API from the previous article. Install the extra packages we need:

npm install jsonwebtoken bcrypt

Add a secret key to your .env file:

JWT_SECRET=your_super_secret_key_here
JWT_EXPIRES_IN=24h

Make your JWT secret long and random in production. Something like a 64 character random string. Never commit it to GitHub.

Update your users table to include a password column:

ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL;

Or if you are starting fresh:

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  email VARCHAR(150) UNIQUE NOT NULL,
  password VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

The Backend

Create a new file src/routes/auth.js for all authentication routes.

Register

const express = require('express')
const router = express.Router()
const bcrypt = require('bcrypt')
const jwt = require('jsonwebtoken')
const pool = require('../db')

router.post('/register', async (req, res) => {
  try {
    const { name, email, password } = req.body

    if (!name || !email || !password) {
      return res.status(400).json({ error: 'All fields are required' })
    }

    const userExists = await pool.query(
      'SELECT * FROM users WHERE email = $1',
      [email]
    )

    if (userExists.rows.length > 0) {
      return res.status(400).json({ error: 'Email already in use' })
    }

    const saltRounds = 10
    const hashedPassword = await bcrypt.hash(password, saltRounds)

    const result = await pool.query(
      'INSERT INTO users (name, email, password) VALUES (\(1, \)2, $3) RETURNING id, name, email',
      [name, email, hashedPassword]
    )

    const user = result.rows[0]

    const token = jwt.sign(
      { id: user.id, email: user.email },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_EXPIRES_IN }
    )

    res.status(201).json({ user, token })
  } catch (error) {
    console.error(error)
    res.status(500).json({ error: 'Server error' })
  }
})

Notice that in the RETURNING clause we only return id, name, email and not the password. You should never send the hashed password back to the client, even though it is hashed.

saltRounds: 10 means bcrypt will hash the password 10 times. The higher the number the more secure but the slower it gets. 10 is the recommended balance for most applications.

Login

router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body

    if (!email || !password) {
      return res.status(400).json({ error: 'Email and password are required' })
    }

    const result = await pool.query(
      'SELECT * FROM users WHERE email = $1',
      [email]
    )

    if (result.rows.length === 0) {
      return res.status(401).json({ error: 'Invalid credentials' })
    }

    const user = result.rows[0]

    const passwordMatch = await bcrypt.compare(password, user.password)

    if (!passwordMatch) {
      return res.status(401).json({ error: 'Invalid credentials' })
    }

    const token = jwt.sign(
      { id: user.id, email: user.email },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_EXPIRES_IN }
    )

    res.json({
      user: { id: user.id, name: user.name, email: user.email },
      token
    })
  } catch (error) {
    console.error(error)
    res.status(500).json({ error: 'Server error' })
  }
})

module.exports = router

Notice we return the same error message Invalid credentials for both a wrong email and a wrong password. This is intentional. If you return different messages like User not found vs Wrong password, you are telling attackers which emails exist in your database.

Verify Middleware

Create a new file src/middleware/auth.js:

const jwt = require('jsonwebtoken')

const verifyToken = (req, res, next) => {
  const authHeader = req.headers['authorization']
  const token = authHeader && authHeader.split(' ')[1]

  if (!token) {
    return res.status(401).json({ error: 'Access denied. No token provided.' })
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET)
    req.user = decoded
    next()
  } catch (error) {
    return res.status(403).json({ error: 'Invalid or expired token.' })
  }
}

module.exports = verifyToken

We expect the token to come in the Authorization header in this format:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

authHeader.split(' ')[1] extracts just the token part after Bearer. If the token is valid, jwt.verify returns the decoded payload and we attach it to req.user so the route handler can use it.

Protecting a Route

Now let's use the middleware to protect a route. Add this to your src/routes/users.js:

const verifyToken = require('../middleware/auth')

router.get('/me', verifyToken, async (req, res) => {
  try {
    const result = await pool.query(
      'SELECT id, name, email, created_at FROM users WHERE id = $1',
      [req.user.id]
    )

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'User not found' })
    }

    res.json(result.rows[0])
  } catch (error) {
    console.error(error)
    res.status(500).json({ error: 'Server error' })
  }
})

The verifyToken middleware sits between the route path and the handler function. If the token is missing or invalid, the middleware returns an error and the handler never runs. If the token is valid, req.user will contain the decoded payload with the user's ID.

Register the auth routes in src/index.js:

const authRouter = require('./routes/auth')
app.use('/api/auth', authRouter)

The Frontend

Now let's handle the token on the React side.

Storing the Token After Login

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

  const data = await response.json()

  if (response.ok) {
    localStorage.setItem('token', data.token)
  }

  return data
}

Decoding the Token on the Frontend

You do not need a library to decode a JWT on the frontend. The payload is just base64 encoded so you can decode it manually:

const decodeToken = (token) => {
  try {
    const base64Payload = token.split('.')[1]
    const payload = atob(base64Payload)
    return JSON.parse(payload)
  } catch (error) {
    return null
  }
}

const token = localStorage.getItem('token')
const user = token ? decodeToken(token) : null

This gives you access to whatever you put in the payload when you signed the token, like the user's ID and email, without making an extra API call.

Checking Token Expiry on Window Focus

This is something I implemented in my Taskflow app and it works really well. Instead of waiting for a failed API call to tell the user their session expired, we check proactively every time the user comes back to the tab:

const isTokenExpired = (token) => {
  const decoded = decodeToken(token)
  if (!decoded) return true
  const currentTime = Date.now() / 1000
  return decoded.exp < currentTime
}

useEffect(() => {
  const handleFocus = () => {
    const token = localStorage.getItem('token')
    if (token && isTokenExpired(token)) {
      localStorage.removeItem('token')
      window.location.href = '/login'
    }
  }

  window.addEventListener('focus', handleFocus)
  return () => window.removeEventListener('focus', handleFocus)
}, [])

Every time the user switches back to your app, this function checks if the token has expired. If it has, it clears it from localStorage and redirects to the login page. The user never gets stuck in a broken state where they think they are logged in but their requests are failing.

Sending the Token with Every Request

const getAuthHeader = () => {
  const token = localStorage.getItem('token')
  return token ? { Authorization: `Bearer ${token}` } : {}
}

const fetchProfile = async () => {
  const response = await fetch('/api/users/me', {
    headers: {
      'Content-Type': 'application/json',
      ...getAuthHeader()
    }
  })
  return response.json()
}

If you are using React Query like I did in Taskflow, you just include the auth header in your query function the same way.

OWASP Best Practices

OWASP is the Open Web Application Security Project and they publish guidelines on how to handle authentication securely. Here is what matters most for JWT:

Never store sensitive data in the token payload. The payload is base64 encoded, not encrypted. Anyone who gets the token can decode it and read the payload. Only store non-sensitive identifiers like user ID and email.

Keep expiry times short. A token that expires in 24 hours limits the damage if it gets stolen. For highly sensitive applications, use 15 to 60 minutes and implement refresh tokens.

localStorage vs httpOnly cookies. Storing tokens in localStorage is the most common approach and the easiest to implement, which is what I used in Taskflow. However OWASP recommends httpOnly cookies because JavaScript cannot read them, which means an XSS attack cannot steal the token. If you are building something that handles sensitive data like payments or medical records, use httpOnly cookies. For most apps, localStorage with an expiry check is a reasonable tradeoff.

Always use HTTPS in production. A JWT sent over plain HTTP can be intercepted. HTTPS encrypts the connection so the token cannot be read in transit.

Use a strong secret key. Your JWT secret should be long, random, and stored securely in environment variables. Never hardcode it or commit it to GitHub.

Conclusion

JWT authentication connects your frontend and backend in a stateless, scalable way. The pattern we covered here, hashing passwords with bcrypt, signing tokens on login, verifying on the backend, and decoding on the frontend, is the foundation of auth in most production JavaScript applications.

The expiry check on window focus is a small addition that makes a big difference in user experience and security. Once you have this working, the next step is adding refresh tokens so users stay logged in without compromising security.

Found this helpful? Follow me on Hashnode, connect with me on LinkedIn, and follow me on X for more articles on full stack development, React, Node.js, and backend architecture.

3 views