Learn how to build a secure Node.js Express backend with PostgreSQL authentication, JWT authorization, and role-based access control in this step-by-step guide.
Lukas
Created: January 16, 2025
Updated: February 17, 2025
Our mission is to make the Internet a safer place, and the new login standard passkeys provides a superior solution to achieve that. That's why we want to help you understand passkeys and its characteristics better.
In this tutorial, we'll build a modern Node.js Express REST API that supports token-based authentication using JWT (JSON Web Token) and PostgreSQL. By the end of this guide, you'll understand:
Let's dive in!
Unlike session-based authentication, which stores session data on the server and relies on cookies, token-based authentication stores the JWT on the client side - such as in Local Storage for browsers, Keychain for iOS, or SharedPreferences for Android. This approach eliminates the need for an additional backend to support native apps or separate authentication modules.
A JWT consists of three parts:
These parts are concatenated into a standard structure: header.payload.signature
.
Clients typically include the JWT in the following headers:
Authorization Header with Bearer Prefix:
Authorization: Bearer <token>
Custom Header:
x-access-token: <token>
PostgreSQL is a robust and scalable relational database that integrates seamlessly with Node.js and Express for building secure and efficient backend systems. It offers several benefits for implementing authentication and authorization:
By leveraging PostgreSQL, you can ensure a scalable and secure solution for role-based authentication in your Node.js backend.
We'll build a Node.js Express application where users can:
admin
, moderator
, user
).Required APIs:
Method | URL | Action |
---|---|---|
POST | /api/auth/signup | Register a new account |
POST | /api/auth/signin | Log in to an account |
GET | /api/test/all | Retrieve public content |
GET | /api/test/user | Access user content |
GET | /api/test/mod | Access moderator content |
GET | /api/test/admin | Access admin content |
JWT (JSON Web Token) is a lightweight, stateless authentication method ideal for REST APIs. Here's how it integrates with PostgreSQL:
jwt.verify(token, authConfig.secret, (err, decoded) => { if (err) return res.status(401).json({ message: 'Unauthorized!' }); req.userId = decoded.id; next(); });
This setup ensures that only authorized users can access protected resources.
user
).Role-based authorization in Node.js Express using PostgreSQL:
Role-based authorization allows applications to enforce access control by assigning specific permissions to users. Here’s how it works in a Node.js Express backend:
db.role.belongsToMany(db.user, { through: "user_roles" }); db.user.belongsToMany(db.role, { through: "user_roles" });
const role = await Role.findOne({ where: { name: 'user' } }); await user.setRoles([role]);
const isAdmin = async (req, res, next) => { const user = await User.findByPk(req.userId); const roles = await user.getRoles(); if (roles.some(role => role.name === 'admin')) { return next(); } return res.status(403).json({ message: 'Require Admin Role!' }); };
This structure ensures fine-grained access control, making it easier to secure sensitive endpoints.
Security Note: Ensure that the JWT is securely stored on the client side to prevent unauthorized access.
You also need to refresh tokens when they expire:
JWT provides several benefits when securing Node.js Express applications:
Managing roles is critical for secure and efficient access control. PostgreSQL simplifies this with its robust data management capabilities:
const role = await Role.findOne({ where: { name: 'user' } }); await user.setRoles([role]);
const updateRoles = async (userId, newRoles) => { const user = await User.findByPk(userId); const roles = await Role.findAll({ where: { name: newRoles } }); await user.setRoles(roles); };
const isModerator = async (req, res, next) => { const roles = await req.user.getRoles(); if (roles.some(role => role.name === 'moderator')) { return next(); } return res.status(403).json({ message: 'Require Moderator Role!' }); };
Our architecture comprises:
node-js-jwt-auth-postgresql/ ├── app/ │ ├── config/ │ │ ├── auth.config.js │ │ └── db.config.js │ ├── controllers/ │ │ ├── auth.controller.js │ │ └── user.controller.js │ ├── middlewares/ │ │ ├── authJwt.js │ │ └── verifySignUp.js │ ├── models/ │ │ ├── index.js │ │ ├── role.model.js │ │ └── user.model.js │ └── routes/ │ ├── auth.routes.js │ └── user.routes.js ├── server.js ├── package.json └── tailwind.config.js
Create a new directory and initialize a Node.js project:
mkdir node-js-jwt-auth-postgresql cd node-js-jwt-auth-postgresql npm init -y
Install the necessary packages:
npm install express sequelize pg pg-hstore cors jsonwebtoken bcryptjs
To enable ES Module syntax, update package.json
:
{ "type": "module", // ... other configurations }
Create a server.js
file in the root directory:
// server.js import express from 'express'; import cors from 'cors'; import db from './app/models/index.js'; import authRoutes from './app/routes/auth.routes.js'; import userRoutes from './app/routes/user.routes.js'; const app = express(); const corsOptions = { origin: 'http://localhost:8081' }; app.use(cors(corsOptions)); // Parse requests of content-type - application/json app.use(express.json()); // Parse requests of content-type - application/x-www-form-urlencoded app.use(express.urlencoded({ extended: true })); // Simple route app.get('/', (req, res) => { res.json({ message: 'Welcome to the Node.js JWT Authentication application.' }); }); // Routes app.use('/api/auth', authRoutes); app.use('/api/test', userRoutes); // Set port, listen for requests const PORT = process.env.PORT || 8080; db.sequelize.sync().then(() => { app.listen(PORT, () => { console.log(`Server is running on port ${PORT}.`); }); });
Explanation:
http://localhost:8081
.Create a configuration file for the database in app/config/db.config.js
:
// app/config/db.config.js export default { HOST: 'localhost', USER: 'postgres', PASSWORD: 'your_password', DB: 'testdb', dialect: 'postgres', pool: { max: 5, min: 0, acquire: 30000, idle: 10000 } };
Set up Sequelize in app/models/index.js
:
// app/models/index.js import Sequelize from "sequelize"; import dbConfig from "../config/db.config.js"; import UserModel from "./user.model.js"; import RoleModel from "./role.model.js"; const sequelize = new Sequelize(dbConfig.DB, dbConfig.USER, dbConfig.PASSWORD, { host: dbConfig.HOST, dialect: dbConfig.dialect, pool: dbConfig.pool }); const db = {}; db.Sequelize = Sequelize; db.sequelize = sequelize; db.user = UserModel(sequelize, Sequelize); db.role = RoleModel(sequelize, Sequelize); db.role.belongsToMany(db.user, { through: "user_roles" }); db.user.belongsToMany(db.role, { through: "user_roles", as: "roles" }); db.ROLES = ["user", "admin", "moderator"]; export default db;
Explanation:
User
and Role
models.users
and roles
through the user_roles
table.If you do not have a local PostgreSQL instance available for testing already, you can set up a temporary local database using Docker:
docker run --rm -e POSTGRES_PASSWORD=your_password -e POSTGRES_USER=postgres -e POSTGRES_DB=testdb -p 5432:5432 postgres
Create app/models/user.model.js
:
// app/models/user.model.js export default (sequelize, DataTypes) => { const User = sequelize.define('users', { username: { type: DataTypes.STRING, unique: true }, email: { type: DataTypes.STRING, unique: true, validate: { isEmail: true } }, password: { type: DataTypes.STRING } }); return User; };
Create app/models/role.model.js
:
// app/models/role.model.js export default (sequelize, DataTypes) => { const Role = sequelize.define('roles', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.STRING } }); return Role; };
Security Enhancement: By making username
and email
unique and validating email formats, we enhance data integrity and security.
Create app/config/auth.config.js
to store your secret key:
// app/config/auth.config.js export default { secret: 'your-very-secure-secret-key' };
Security Note: Use a strong, unpredictable secret key and store it securely, preferably using environment variables.
Create app/middlewares/verifySignUp.js
:
// app/middlewares/verifySignUp.js import db from '../models/index.js'; const ROLES = db.ROLES; const User = db.user; const checkDuplicateUsernameOrEmail = async (req, res, next) => { try { // Check if username exists let user = await User.findOne({ where: { username: req.body.username } }); if (user) { return res.status(400).json({ message: 'Failed! Username is already in use!' }); } // Check if email exists user = await User.findOne({ where: { email: req.body.email } }); if (user) { return res.status(400).json({ message: 'Failed! Email is already in use!' }); } next(); } catch (error) { res.status(500).json({ message: error.message }); } }; const checkRolesExisted = (req, res, next) => { if (req.body.roles) { for (const role of req.body.roles) { if (!ROLES.includes(role)) { return res.status(400).json({ message: `Failed! Role does not exist: ${role}` }); } } } next(); }; const verifySignUp = { checkDuplicateUsernameOrEmail, checkRolesExisted }; export default verifySignUp;
Create app/middlewares/authJwt.js
:
// app/middlewares/authJwt.js import jwt from 'jsonwebtoken'; import db from '../models/index.js'; import authConfig from '../config/auth.config.js'; const User = db.user; const verifyToken = (req, res, next) => { const token = req.headers['x-access-token'] || req.headers['authorization']; if (!token) { return res.status(403).json({ message: 'No token provided!' }); } const actualToken = token.startsWith('Bearer ') ? token.slice(7, token.length) : token; jwt.verify(actualToken, authConfig.secret, (err, decoded) => { if (err) { return res.status(401).json({ message: 'Unauthorized!' }); } req.userId = decoded.id; next(); }); }; const isAdmin = async (req, res, next) => { try { const user = await User.findByPk(req.userId); const roles = await user.getRoles(); for (const role of roles) { if (role.name === 'admin') { return next(); } } return res.status(403).json({ message: 'Require Admin Role!' }); } catch (error) { res.status(500).json({ message: error.message }); } }; const isModerator = async (req, res, next) => { try { const user = await User.findByPk(req.userId); const roles = await user.getRoles(); for (const role of roles) { if (role.name === 'moderator') { return next(); } } return res.status(403).json({ message: 'Require Moderator Role!' }); } catch (error) { res.status(500).json({ message: error.message }); } }; const isModeratorOrAdmin = async (req, res, next) => { try { const user = await User.findByPk(req.userId); const roles = await user.getRoles(); for (const role of roles) { if (role.name === 'moderator' || role.name === 'admin') { return next(); } } return res.status(403).json({ message: 'Require Moderator or Admin Role!' }); } catch (error) { res.status(500).json({ message: error.message }); } }; const authJwt = { verifyToken, isAdmin, isModerator, isModeratorOrAdmin }; export default authJwt;
Security Enhancements:
Create app/middlewares/index.js
:
// app/middlewares/index.js import authJwt from './authJwt.js'; import verifySignUp from './verifySignUp.js'; export { authJwt, verifySignUp };
Create app/controllers/auth.controller.js
:
// app/controllers/auth.controller.js import db from '../models/index.js'; import authConfig from '../config/auth.config.js'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; const User = db.user; const Role = db.role; export const signup = async (req, res) => { try { // Create new user const hashedPassword = await bcrypt.hash(req.body.password, 10); const user = await User.create({ username: req.body.username, email: req.body.email, password: hashedPassword }); const role = await Role.findOne({where: {name: 'user'}}); await user.setRoles([role]); res.status(201).json({message: 'User registered successfully!'}); } catch (error) { res.status(500).json({message: error.message}); } }; export const signin = async (req, res) => { try { // Find user by username const user = await User.findOne({ where: { username: req.body.username } }); if (!user) { return res.status(404).json({message: 'User Not found.'}); } // Validate password const passwordIsValid = await bcrypt.compare(req.body.password, user.password); if (!passwordIsValid) { return res.status(401).json({ accessToken: null, message: 'Invalid Password!' }); } // Generate JWT const token = jwt.sign({id: user.id}, authConfig.secret, { expiresIn: 86400 // 24 hours }); // Get user roles const roles = await user.getRoles(); const authorities = roles.map(role => `ROLE_${role.name.toUpperCase()}`); res.status(200).json({ id: user.id, username: user.username, email: user.email, roles: authorities, accessToken: token }); } catch (error) { res.status(500).json({message: error.message}); } };
Create app/controllers/user.controller.js
:
// app/controllers/user.controller.js export const allAccess = (req, res) => { res.status(200).send('Public Content.'); }; export const userBoard = (req, res) => { res.status(200).send('User Content.'); }; export const moderatorBoard = (req, res) => { res.status(200).send('Moderator Content.'); }; export const adminBoard = (req, res) => { res.status(200).send('Admin Content.'); };
Create app/routes/auth.routes.js
:
// app/routes/auth.routes.js import express from 'express'; import { signup, signin } from '../controllers/auth.controller.js'; import { verifySignUp } from '../middlewares/index.js'; const router = express.Router(); // Signup Route router.post( '/signup', [ verifySignUp.checkDuplicateUsernameOrEmail, verifySignUp.checkRolesExisted ], signup ); // Signin Route router.post('/signin', signin); export default router;
Create app/routes/user.routes.js
:
// app/routes/user.routes.js import express from 'express'; import { allAccess, userBoard, moderatorBoard, adminBoard } from '../controllers/user.controller.js'; import { authJwt } from '../middlewares/index.js'; const router = express.Router(); // Public Route router.get('/all', allAccess); // User Route router.get('/user', [authJwt.verifyToken], userBoard); // Moderator Route router.get('/mod', [authJwt.verifyToken, authJwt.isModerator], moderatorBoard); // Admin Route router.get('/admin', [authJwt.verifyToken, authJwt.isAdmin], adminBoard); export default router;
Ensure PostgreSQL is running and the database testdb
is created. Then, start the server:
node server.js
You should see:
Server is running on port 8080.
Implement role initialization in server.js
to populate the roles
table:
// server.js (add below the import statements) const initializeRoles = async () => { const roles = ['user', 'moderator', 'admin']; for (const role of roles) { await db.role.findOrCreate({ where: { name: role } }); } }; db.sequelize.sync().then(async () => { await initializeRoles(); app.listen(PORT, () => { console.log(`Server is running on port ${PORT}.`); }); });
Use tools like Postman or Insomnia to test the API endpoints.
Endpoint: POST /api/auth/signup
Body:
{ "username": "john_doe", "email": "john@example.com", "password": "securePassword123" }
Endpoint: POST /api/auth/signin
Body:
{ "username": "john_doe", "password": "securePassword123" }
Response:
{ "id": 1, "username": "john_doe", "email": "john@example.com", "roles": ["ROLE_ADMIN"], "accessToken": "your.jwt.token.here" }
Include the accessToken
in the Authorization
header as a Bearer token.
Header:
Authorization: Bearer your.jwt.token.here
Endpoints:
GET /api/test/user
- Accessible to authenticated users.GET /api/test/mod
- Accessible to moderators.GET /api/test/admin
- Accessible to admins.Avoid these common pitfalls to ensure secure and effective JWT implementation:
Congratulations! You've successfully built a secure and modern Node.js Express REST API with JWT authentication and PostgreSQL using Sequelize. This setup provides a solid foundation for user authentication and role-based authorization, ensuring that your application adheres to current best practices.
Next Steps:
Happy coding!
Table of Contents
Enjoyed this read?
🤝 Join our Passkeys Community
Share passkeys implementation tips and get support to free the world from passwords.
🚀 Subscribe to Substack
Get the latest news, strategies, and insights about passkeys sent straight to your inbox.
Related Articles