DEV Community

Cover image for I Built a JWT Authentication API from Scratch with Express, Prisma & Supabase — Here's Everything I Learned
Chinwuba
Chinwuba

Posted on

I Built a JWT Authentication API from Scratch with Express, Prisma & Supabase — Here's Everything I Learned

I'm currently 9 weeks into a 16-week Express.js roadmap I set for myself. The goal at the end is to build a full client portal for my agency, Velto — with client login, project tracking, invoices, and Paystack payments.

This week I hit the JWT auth checkpoint. Register, login, protected routes, refresh tokens, logout. All of it from scratch, no Auth0, no Supabase magic auth, no shortcuts.

This post is everything — the concepts, the code, the mistakes, and the things most tutorials gloss over.

What is a JWT, actually?

A JWT (JSON Web Token) is a string with three base64-encoded parts separated by dots:

header.payload.signature

The header tells you the algorithm used. The payload is your actual data — userId, email, whatever you put in. The signature is the header and payload hashed together with your secret key.
Here's the critical thing most tutorials don't hammer home enough: the header and payload are not encrypted. Anyone can decode them. What they cannot do is forge the signature without your secret key. That's the entire security model. The server doesn't need to store the token or look it up in a database on every request — it just checks if the signature is valid.
Think of it like a government-issued ID. Anyone can read what's on the card. Nobody can fake the hologram.

Access Tokens vs Refresh Tokens — Why You Need Both

This is where most beginner JWT tutorials fall apart. They give you one token, store it in localStorage, call it done. That's a problem.

If you make your access token last 30 days, a stolen token is a 30-day problem. If you make it last 15 minutes, users have to log in every 15 minutes. Neither is acceptable.

The solution is two tokens:

Access token — short-lived (15 minutes). Sent with every request in the Authorization header. Small blast radius if stolen.

Refresh token — long-lived (7 days). Stored in an httpOnly cookie. Used only to get a new access token when the current one expires. Never touches localStorage.

httpOnly cookies cannot be accessed by JavaScript at all — not by your code, not by malicious scripts. This is why we use them for the refresh token. XSS attacks can't steal what they can't read.

Project Structure

jwt-auth-api/
  src/
    controllers/
      authController.js
    middleware/
      authMiddleware.js
    routes/
      authRoute.js
    app.js
    prismaClient.js
  index.js
  prisma/
    schema.prisma
  .env
Enter fullscreen mode Exit fullscreen mode

]
Clean separation from the start. Controllers handle logic. Routes handle mapping. Middleware handles gatekeeping.

The Prisma Setup

One thing that burned me early: Prisma 6 changed the generator syntax. If you're on Prisma 6 and you see provider = "prisma-client" auto-generated in your schema, that generates TypeScript output files into a local folder — which Node.js can't run directly in a CommonJS project.

The fix is to use the classic generator:

js
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
Enter fullscreen mode Exit fullscreen mode

This puts the compiled client in node_modules/@prisma/client where Node can find it normally. Import it like:

js
const { PrismaClient } = require('@prisma/client')
Enter fullscreen mode Exit fullscreen mode

Also — if you're on Supabase free tier, use npx prisma db push instead of npx prisma migrate dev. The migrate command requires a shadow database that Supabase free tier doesn't provide. db push syncs your schema directly and is fine for development.

The User model for this project:

prisma
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String
  createdAt DateTime @default(now())
}
Enter fullscreen mode Exit fullscreen mode

One shared Prisma instance across the entire app:

js
// src/prismaClient.js
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()

module.exports = prisma
Enter fullscreen mode Exit fullscreen mode

Never instantiate PrismaClient in multiple files — each instance opens a connection pool. One file, one instance, export it everywhere.

The Register Route

js
async function register(req, res, next) {
  const { email, password } = req.body
  try {
    const existingUser = await prisma.user.findUnique({
      where: { email }
    })

    if (existingUser) {
      return res.status(409).json('That email is already taken')
    }

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

    const newUser = await prisma.user.create({
      data: { email, password: hash }
    })

    res.status(201).json({
      userId: newUser.id,
      email: newUser.email
    })
  } catch (err) {
    next(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

bcrypt.hash() is asynchronous — it returns a Promise. Forgetting await here means you store the string [object Promise] in your database. Your users will never be able to log in again because their password is now literally a Promise object.
Salt rounds of 10 is the standard. It controls how computationally expensive the hash is. Higher = more secure, slower. 10 is the industry default balance.
Never return the hashed password in your response. Return only what the client actually needs — id and email.

The Login Route

js
async function login(req, res, next) {
  const { email, password } = req.body
  try {
    const user = await prisma.user.findUnique({ where: { email } })

    if (!user) {
      return res.status(404).json('No account found with that email')
    }

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

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

    const accessToken = jwt.sign(
      { id: user.id, email: user.email },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    )

    const refreshToken = jwt.sign(
      { id: user.id, email: user.email },
      process.env.JWT_REFRESH_SECRET,
      { expiresIn: '7d' }
    )

    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    })

    return res.status(200).json({
      id: user.id,
      email: user.email,
      accessToken
    })
  } catch (err) {
    next(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

bcrypt.compare() is what does the verification. You never decrypt a bcrypt hash — it's a one-way function. Instead, compare() hashes the submitted password the same way and checks if the outputs match.

Notice the two different secrets. Access token uses JWT_SECRET. Refresh token uses JWT_REFRESH_SECRET. This is intentional — if your access token secret leaks, an attacker cannot forge refresh tokens. They're completely independent.

The cookie options matter:
httpOnly: true — JavaScript cannot read this cookie. Period.
sameSite: 'strict' — the cookie is never sent on cross-site requests, which prevents CSRF attacks
maxAge — set to match the refresh token expiry so they expire together

Auth Middleware

This is the gatekeeper. Every protected route runs through this function first.

js
const jwt = require('jsonwebtoken')

function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization

  if (!authHeader) {
    return res.status(401).json('No authorization header')
  }

  const token = authHeader.split(' ')[1]

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

module.exports = authMiddleware
Enter fullscreen mode Exit fullscreen mode

The Authorization header arrives as a string: Bearer eyJhbGci.... Split on the space, grab index 1.

jwt.verify() is synchronous by default. It does two things: validates the signature and checks if the token has expired. If either fails, it throws. That's why the try/catch is essential — you can't use if (!decoded) because verify doesn't return null on failure, it throws an error.

Once verified, the decoded payload — which contains the id and email you signed in during login — gets attached to req.user. Every protected route then has access to the current user without a database lookup.

The Refresh Route

js
function refresh(req, res, next) {
  const refreshToken = req.cookies.refreshToken

  try {
    if (!refreshToken) {
      return res.status(401).json('No refresh token')
    }

    const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET)

    const newAccessToken = jwt.sign(
      { id: decoded.id, email: decoded.email },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    )

    return res.status(200).json(newAccessToken)
  } catch (err) {
    next(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

The secret rule: verify with the same secret you signed with. The refresh token was signed with JWT_REFRESH_SECRET during login, so you verify it with JWT_REFRESH_SECRET here. The new access token is signed with JWT_SECRET because that's what the auth middleware expects.

req.cookies.refreshToken — this is why cookie-parser is registered in app.js. Without it, req.cookies doesn't exist.

Logout

js
function logout(req, res) {
  res.clearCookie('refreshToken')
  res.status(200).json({ message: 'Logged out' })
}
Enter fullscreen mode Exit fullscreen mode

Simple. Clear the cookie. The access token will naturally expire in 15 minutes — there's no server-side revocation needed at this stage.

The Routes

js
const express = require('express')
const router = express.Router()
const { register, login, logout, refresh } = require('../controllers/authController')
const authMiddleware = require('../middleware/authMiddleware')

router.post('/register', register)
router.post('/login', login)
router.post('/logout', logout)
router.post('/refresh', refresh)
router.get('/me', authMiddleware, (req, res) => res.json(req.user))

module.exports = router
Enter fullscreen mode Exit fullscreen mode

/me is the protected route. authMiddleware sits between the route and the handler — Express runs them in order. If the middleware calls next(), the handler runs. If it returns a response, the handler never runs.

What the .env needs

DATABASE_URL=your_supabase_connection_string
JWT_SECRET=make_this_long_and_random
JWT_REFRESH_SECRET=make_this_different_and_also_long
PORT=3000
Enter fullscreen mode Exit fullscreen mode

Generate your secrets with something like:

node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Enter fullscreen mode Exit fullscreen mode

If you're building something similar, the thing most tutorials skip is the two-token architecture. Get that right and everything else falls into place.

Remember write code as art

Top comments (0)