DEV Community

Cover image for How I Handle JWT Authentication in Express.js (Without the Headaches)
Bishop Abraham
Bishop Abraham

Posted on • Originally published at abraham-bishop.hashnode.dev

How I Handle JWT Authentication in Express.js (Without the Headaches)

Authentication used to stress me out.

Not because it's conceptually hard, but because every tutorial I found either oversimplified it to the point of being useless, or made it so complicated I needed a PhD to understand what was happening.

After building several projects and breaking authentication in creative ways, I finally figured out a setup that actually works and doesn't make me want to throw my laptop out the window.

suprised in a good way

Here's how I do it, explained like I'm talking to a friend over coffee, not writing a textbook.

What We're Working With

  • Node.js & Express - The backend

  • MongoDB & Mongoose - Where user data lives

  • bcryptjs - Makes passwords unreadable (important!)

  • jsonwebtoken - Creates those tokens everyone talks about

  • cookie-parser - Handles cookies properly

  • dotenv - Keeps secrets actually secret

Nothing fancy. Nothing you can't install in 30 seconds.

Step 1: Letting People Sign Up

When someone registers, we need to save their info. But we can't just store their password as-is. That's like leaving your house key under the doormat with a sign that says "KEY HERE."

const bcrypt = require('bcryptjs');
const User = require('../models/User');

app.post('/api/register', async (req, res) => {
  const { username, email, password } = req.body;

  // Turn the password into scrambled nonsense
  const hashedPassword = await bcrypt.hash(password, 10);

  const user = new User({ username, email, password: hashedPassword });

  try {
    await user.save();
    res.status(201).json({ message: 'User created successfully' });
  } catch (err) {
    res.status(400).json({ error: 'User already exists or invalid input' });
  }
});
Enter fullscreen mode Exit fullscreen mode

What's happening: We take their password and scramble it (that's the hashing part). Even if someone breaks into your database, they just see gibberish instead of actual passwords.

The hacker after breaking into your database:

hacker after breaking into database reaction

Step 2: Logging In and Getting Tokens

This is where it gets interesting. When someone logs in, we give them TWO tokens:

  • Access token - Short-lived (15 minutes). Like a temporary pass to get into places.

  • Refresh token - Lasts longer (5 days). Used to get new access tokens without logging in again.

Why two? Security. If someone steals your access token, it expires quickly. The refresh token stays safe in a cookie.

const jwt = require('jsonwebtoken');

const generateAccessToken = (payload) => {
    return jwt.sign(payload, process.env.JWT_SECRET, {expiresIn: '15m'})
}

app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;

  // Find the user
  const user = await User.findOne({ email });
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  // Check if password matches
  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) return res.status(401).json({ error: 'Invalid credentials' });

  // Package up user info
  const payload = {
    id: user._id,
    username: user.username,
    email: user.email
  }

  // Create both tokens
  const refreshToken = jwt.sign(
    payload,
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '5d' }
  );

  const accessToken = generateAccessToken(payload);

  // Send refresh token as a secure cookie
  res.cookie("refreshToken", refreshToken, {
    secure: true,
    httpOnly: true,
    maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days
  })

  res.status(200).json({ accessToken });
});
Enter fullscreen mode Exit fullscreen mode

What's happening: We check if the user exists, verify their password, then create two tokens. The refresh token goes in a special cookie that JavaScript can't touch (that's the httpOnly part). The access token goes in the response.

Step 3: Getting a New Access Token

After 15 minutes, your access token expires. Instead of making users log in again, we use the refresh token to get a new one.

First, set up cookie parsing in your main file:

const cookieParser = require("cookie-parser");
app.use(cookieParser());
Enter fullscreen mode Exit fullscreen mode

Then create the refresh endpoint:

app.post('/token/refresh', (req, res) => {
  // Get the refresh token from the cookie
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) return res.status(401).json({ error: 'No token' });

  // Verify it's legit
  const verifiedToken = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
  if (!verifiedToken) return res.status(403).json({ error: 'Invalid token' });

  // Create a new access token
  const {id, username, email} = verifiedToken;
  const accessToken = generateAccessToken({id, username, email});

  res.status(200).json({ accessToken });
});
Enter fullscreen mode Exit fullscreen mode

What's happening: We grab the refresh token from the cookie, check if it's valid, and if it is, we create a fresh access token. Simple.

inspecting refresh access token meme

Step 4: Protecting Routes

Now we need to make sure only logged-in users can access certain routes. That's what middleware is for.

const authMiddleware = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader) return res.status(401).json({ error: 'No token provided' });

  // Token comes as "Bearer actualtoken", so we split it
  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded; // Now we know who's making the request
    next();
  } catch (err) {
    res.status(403).json({ error: 'Invalid or expired token' });
  }
};

// Use it on any route you want to protect
app.get('/api/profile', authMiddleware, async (req, res) => {
  const user = await User.findById(req.user.id).select('-password');
  res.json(user);
});
Enter fullscreen mode Exit fullscreen mode

What's happening: The middleware checks if there's a valid token. If yes, attach the user info to the request and move on. If no, reject them. Think of it as a bouncer checking IDs.

Why This Setup Works

It's stateless. No need to store sessions on the server. Everything lives in the tokens.

It scales. Works whether you have 10 users or 10,000.

It's secure enough. Passwords are hashed, tokens expire, and the refresh token is protected.

It's not overwhelming. You can understand what each piece does without a computer science degree.

simple but honest work

What I'd Add Next

Right now, this handles basic authentication. But for bigger projects, you might want:

  • Role-based access - Admins can do more than regular users

  • Token blacklisting - Invalidate tokens when users log out

  • Rate limiting - Stop people from spamming your login endpoint

But for most projects? This setup is solid.

The Real Talk

Authentication doesn't have to be scary or complicated. Yes, there are edge cases. Yes, there are more secure ways to do things. But this approach has worked for my projects, and it's simple enough that I can set it up in an hour without pulling my hair out.

If you're just starting with backend auth or you've been putting it off because it seems overwhelming, start here. Get it working, understand what each part does, then optimize later.

Perfect is the enemy of shipped.

Got questions? Different approach? Let me know in the comments. Always happy to learn better ways to do this.

Goodbye reaction meme

Originally published on My Blog

Top comments (0)