ShasTech-IT · Software & Training

Express.js Authentication with JWT: Complete Login and Registration API

Build a secure Express.js authentication API with JWT, MySQL, and bcrypt. Learn user registration, login, password hashing, protected routes, role authorization, and token security.

One team, end-to-end delivery
W
Web & WordPress
Business sites, portals, e-commerce and plugins.
A
Apps & Business Systems
Mobile apps, ERP, HRMS, WMS, POS and automation.
T
Practical IT Training
Structured courses, student portal and certificates.
Published: 2026-08-01 18:19:58

Article

Authentication allows an application to identify users before giving them access to private data or protected actions. In this tutorial, you will build a complete registration and login API using Node.js, Express.js, MySQL, bcrypt, and JSON Web Tokens.

What You Will Build: A database-backed authentication API with registration, login, password hashing, signed access tokens, protected profile routes, role-based authorization, account-status checks, and secure error handling.

This tutorial extends the project created in Build a Complete CRUD REST API with Express.js and MySQL. You can also use the authentication module as the foundation for a new Express.js application.

What Is Authentication?

Authentication is the process of confirming a user's identity. A user normally submits an email address and password, and the server checks whether those credentials match an account stored in the database.

Authentication vs Authorization

Concept Question Answered Example
Authentication Who is this user? Login with an email and password
Authorization What is this user allowed to do? Only administrators may delete users
Authentication identifies the user. Authorization checks the user's permissions after identity has been established.

What Is JWT?

JWT stands for JSON Web Token. It is a compact token format commonly used to carry signed claims between a client and server.

A JWT normally contains three sections:

header.payload.signature
  • Header: Identifies the token type and signing algorithm.
  • Payload: Contains claims such as user ID, role, issuer, audience, and expiration.
  • Signature: Allows the server to detect unauthorized token modification.
A signed JWT is not encrypted by default. Do not put passwords, private keys, payment details, or other sensitive information inside the token payload.

Authentication Flow

1. User submits registration details


2. Server validates the input
3. Password is hashed with bcrypt
4. User account is saved in MySQL
5. User submits login credentials
6. Server compares the password with the stored hash
7. Server issues a signed access token
8. Client sends the token with protected requests
9. Server verifies the token and loads the current user
10. Authorized request continues to the controller

Technologies Used

Technology Purpose
Express.js API routes and middleware
MySQL User account storage
MySQL2 Parameterized database queries
bcrypt Password hashing and comparison
jsonwebtoken JWT signing and verification
dotenv Environment-variable configuration

Prerequisites

  • Node.js and npm installed
  • MySQL or MariaDB installed
  • Basic Express.js knowledge
  • An API-testing tool such as Postman
  • The previous Express.js MySQL project or a new Express application

Developers managing multiple Node.js releases can read How to Use NVM (Node Version Manager) Like a Pro.

Step 1: Create the Project

mkdir express-jwt-auth-api


cd express-jwt-auth-api
npm init -y
New to package management? Read What Is NPM? Complete Guide for Beginners.

Step 2: Install Dependencies

npm install express mysql2 dotenv bcrypt jsonwebtoken

Install Nodemon for development:

npm install --save-dev nodemon

Update package.json:

{
"scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
}


}

Step 3: Create the Database

CREATE DATABASE express_auth_api


CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;

USE express_auth_api;

Step 4: Create the Users Table

CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(120) NOT NULL,
email VARCHAR(190) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('user', 'admin') NOT NULL DEFAULT 'user',
status ENUM('active', 'inactive', 'blocked')
    NOT NULL DEFAULT 'active',
token_version INT UNSIGNED NOT NULL DEFAULT 1,
last_login_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
    ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_users_email (email),
KEY idx_users_role (role),
KEY idx_users_status (status)


) ENGINE=InnoDB;

Why Store password_hash Instead of password?

The application must never save a user's plain-text password. It stores a one-way password hash generated by bcrypt.

What Is token_version?

The token version gives the server a simple way to invalidate previously issued access tokens. Incrementing this value can force a user to authenticate again after a password change, security incident, or administrative action.

Step 5: Create the Project Structure

express-jwt-auth-api/


│
├── src/
│   ├── config/
│   │   └── database.js
│   ├── controllers/
│   │   ├── authController.js
│   │   └── userController.js
│   ├── middleware/
│   │   ├── asyncHandler.js
│   │   ├── authenticate.js
│   │   ├── authorize.js
│   │   ├── errorHandler.js
│   │   └── notFound.js
│   ├── models/
│   │   └── userModel.js
│   ├── routes/
│   │   ├── authRoutes.js
│   │   └── userRoutes.js
│   ├── services/
│   │   └── authService.js
│   ├── utils/
│   │   ├── AppError.js
│   │   └── tokenService.js
│   └── app.js
│
├── .env
├── .env.example
├── .gitignore
├── package.json
└── server.js

Step 6: Configure Environment Variables

Create .env:

NODE_ENV=development


PORT=3000

DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=express_auth_api
DB_USER=root
DB_PASSWORD=
DB_CONNECTION_LIMIT=10

BCRYPT_ROUNDS=12

JWT_ACCESS_SECRET=replace_with_a_long_random_secret
JWT_ACCESS_EXPIRES_IN=15m
JWT_ISSUER=shastech-auth-api
JWT_AUDIENCE=shastech-api-users
Generate a long, unpredictable JWT secret. Never commit the real secret or database password to Git.

Generate a Random Secret

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

Create .env.example with placeholder values:

NODE_ENV=development


PORT=3000

DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=database_name
DB_USER=database_user
DB_PASSWORD=database_password
DB_CONNECTION_LIMIT=10

BCRYPT_ROUNDS=12

JWT_ACCESS_SECRET=replace_with_secure_secret
JWT_ACCESS_EXPIRES_IN=15m
JWT_ISSUER=your-auth-api
JWT_AUDIENCE=your-api-users

Step 7: Add .gitignore

node_modules/


.env
npm-debug.log*
coverage/
dist/
.DS_Store

Step 8: Create the Database Pool

Create src/config/database.js:

const mysql = require('mysql2/promise');


const requiredVariables = [
'DB_HOST',
'DB_NAME',
'DB_USER'
];

for (const variableName of requiredVariables) {
if (!process.env[variableName]) {
throw new Error(
`Missing environment variable: ${variableName}`
);
}
}

const pool = mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 3306),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD || '',
waitForConnections: true,
connectionLimit: Number(
process.env.DB_CONNECTION_LIMIT || 10
),
queueLimit: 0,
charset: 'utf8mb4'
});

async function testDatabaseConnection() {
const connection = await pool.getConnection();


try {
    await connection.ping();
    console.log('MySQL connection established');
} finally {
    connection.release();
}


}

module.exports = {
pool,
testDatabaseConnection
};

Step 9: Create AppError

Create src/utils/AppError.js:

class AppError extends Error {
constructor(message, statusCode = 500, details = null) {
    super(message);

    this.name = 'AppError';
    this.statusCode = statusCode;
    this.details = details;
    this.isOperational = true;

    Error.captureStackTrace(this, this.constructor);
}


}

module.exports = AppError;

Step 10: Create the Async Handler

Create src/middleware/asyncHandler.js:

function asyncHandler(handler) {
return function wrappedHandler(req, res, next) {
    Promise.resolve(
        handler(req, res, next)
    ).catch(next);
};


}

module.exports = asyncHandler;

Step 11: Create the User Model

Create src/models/userModel.js:

const { pool } = require('../config/database');


async function findByEmail(email) {
const [rows] = await pool.execute(
`             SELECT
                id,
                name,
                email,
                password_hash,
                role,
                status,
                token_version,
                last_login_at,
                created_at,
                updated_at
            FROM users
            WHERE email = ?
            LIMIT 1
        `,
[email]
);


return rows[0] || null;


}

async function findById(id) {
const [rows] = await pool.execute(
`             SELECT
                id,
                name,
                email,
                password_hash,
                role,
                status,
                token_version,
                last_login_at,
                created_at,
                updated_at
            FROM users
            WHERE id = ?
            LIMIT 1
        `,
[id]
);


return rows[0] || null;


}

async function create({
name,
email,
passwordHash
}) {
const [result] = await pool.execute(
`             INSERT INTO users
                (
                    name,
                    email,
                    password_hash,
                    role,
                    status
                )
            VALUES (?, ?, ?, 'user', 'active')
        `,
[
name,
email,
passwordHash
]
);


return findById(result.insertId);


}

async function updateLastLogin(id) {
await pool.execute(
`             UPDATE users
            SET last_login_at = NOW()
            WHERE id = ?
        `,
[id]
);
}

async function incrementTokenVersion(id) {
await pool.execute(
`             UPDATE users
            SET token_version = token_version + 1
            WHERE id = ?
        `,
[id]
);


return findById(id);


}

async function updatePassword(id, passwordHash) {
await pool.execute(
`             UPDATE users
            SET
                password_hash = ?,
                token_version = token_version + 1
            WHERE id = ?
        `,
[
passwordHash,
id
]
);


return findById(id);


}

async function listUsers() {
const [rows] = await pool.execute(
`             SELECT
                id,
                name,
                email,
                role,
                status,
                last_login_at,
                created_at,
                updated_at
            FROM users
            ORDER BY id DESC
        `
);


return rows;


}

module.exports = {
findByEmail,
findById,
create,
updateLastLogin,
incrementTokenVersion,
updatePassword,
listUsers
};
User-facing responses must never include the password_hash field.

Step 12: Create the Token Service

Create src/utils/tokenService.js:

const jwt = require('jsonwebtoken');


const AppError = require('./AppError');

const algorithm = 'HS256';

function getTokenConfiguration() {
const secret = process.env.JWT_ACCESS_SECRET;
const issuer = process.env.JWT_ISSUER;
const audience = process.env.JWT_AUDIENCE;


if (!secret || !issuer || !audience) {
    throw new Error(
        'JWT secret, issuer, and audience are required'
    );
}

return {
    secret,
    issuer,
    audience
};


}

function signAccessToken(user) {
const {
secret,
issuer,
audience
} = getTokenConfiguration();


return jwt.sign(
    {
        role: user.role,
        version: user.token_version
    },
    secret,
    {
        algorithm,
        subject: String(user.id),
        issuer,
        audience,
        expiresIn:
            process.env.JWT_ACCESS_EXPIRES_IN || '15m'
    }
);


}

function verifyAccessToken(token) {
const {
secret,
issuer,
audience
} = getTokenConfiguration();


try {
    return jwt.verify(
        token,
        secret,
        {
            algorithms: [algorithm],
            issuer,
            audience
        }
    );
} catch (error) {
    if (error.name === 'TokenExpiredError') {
        throw new AppError(
            'Access token has expired',
            401
        );
    }

    throw new AppError(
        'Access token is invalid',
        401
    );
}


}

module.exports = {
signAccessToken,
verifyAccessToken
};

Why Verify the Algorithm Explicitly?

The server should define the accepted signing algorithm instead of selecting it from untrusted token data.

Why Use subject, issuer, and audience?

  • subject: Identifies the user represented by the token.
  • issuer: Identifies the service that created the token.
  • audience: Identifies the intended recipient or API.

Step 13: Create the Authentication Service

Create src/services/authService.js:

const bcrypt = require('bcrypt');


const userModel = require('../models/userModel');
const AppError = require('../utils/AppError');

const {
signAccessToken
} = require('../utils/tokenService');

function normalizeEmail(value) {
return String(value || '')
.trim()
.toLowerCase();
}

function normalizeName(value) {
return String(value || '').trim();
}

function validateRegistrationInput(input) {
const errors = {};


const name = normalizeName(input.name);
const email = normalizeEmail(input.email);
const password = String(input.password || '');
const passwordConfirmation = String(
    input.password_confirmation || ''
);

if (!name) {
    errors.name = 'Name is required';
} else if (name.length < 2 || name.length > 120) {
    errors.name =
        'Name must contain between 2 and 120 characters';
}

if (!email) {
    errors.email = 'Email is required';
} else if (
    !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
) {
    errors.email = 'Enter a valid email address';
} else if (email.length > 190) {
    errors.email =
        'Email cannot exceed 190 characters';
}

if (!password) {
    errors.password = 'Password is required';
} else if (password.length < 12) {
    errors.password =
        'Password must contain at least 12 characters';
} else if (password.length > 72) {
    errors.password =
        'Password cannot exceed 72 characters';
}

if (password !== passwordConfirmation) {
    errors.password_confirmation =
        'Password confirmation does not match';
}

if (Object.keys(errors).length > 0) {
    throw new AppError(
        'Registration validation failed',
        422,
        errors
    );
}

return {
    name,
    email,
    password
};


}

function validateLoginInput(input) {
const errors = {};


const email = normalizeEmail(input.email);
const password = String(input.password || '');

if (!email) {
    errors.email = 'Email is required';
}

if (!password) {
    errors.password = 'Password is required';
}

if (Object.keys(errors).length > 0) {
    throw new AppError(
        'Login validation failed',
        422,
        errors
    );
}

return {
    email,
    password
};


}

function sanitizeUser(user) {
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
status: user.status,
last_login_at: user.last_login_at,
created_at: user.created_at,
updated_at: user.updated_at
};
}

async function register(input) {
const {
name,
email,
password
} = validateRegistrationInput(input);


const existingUser = await userModel.findByEmail(email);

if (existingUser) {
    throw new AppError(
        'An account with this email already exists',
        409
    );
}

const configuredRounds = Number(
    process.env.BCRYPT_ROUNDS || 12
);

const bcryptRounds = Number.isInteger(configuredRounds)
    && configuredRounds >= 10
    && configuredRounds <= 15
    ? configuredRounds
    : 12;

const passwordHash = await bcrypt.hash(
    password,
    bcryptRounds
);

const user = await userModel.create({
    name,
    email,
    passwordHash
});

const accessToken = signAccessToken(user);

return {
    user: sanitizeUser(user),
    accessToken
};


}

async function login(input) {
const {
email,
password
} = validateLoginInput(input);


const user = await userModel.findByEmail(email);

if (!user) {
    throw new AppError(
        'Email or password is incorrect',
        401
    );
}

const passwordMatches = await bcrypt.compare(
    password,
    user.password_hash
);

if (!passwordMatches) {
    throw new AppError(
        'Email or password is incorrect',
        401
    );
}

if (user.status !== 'active') {
    throw new AppError(
        'This account is not available',
        403
    );
}

await userModel.updateLastLogin(user.id);

const currentUser = await userModel.findById(user.id);
const accessToken = signAccessToken(currentUser);

return {
    user: sanitizeUser(currentUser),
    accessToken
};


}

async function changePassword(userId, input) {
const currentPassword = String(
input.current_password || ''
);


const newPassword = String(
    input.new_password || ''
);

const confirmation = String(
    input.new_password_confirmation || ''
);

const errors = {};

if (!currentPassword) {
    errors.current_password =
        'Current password is required';
}

if (newPassword.length < 12) {
    errors.new_password =
        'New password must contain at least 12 characters';
} else if (newPassword.length > 72) {
    errors.new_password =
        'New password cannot exceed 72 characters';
}

if (newPassword !== confirmation) {
    errors.new_password_confirmation =
        'New password confirmation does not match';
}

if (
    currentPassword
    && newPassword
    && currentPassword === newPassword
) {
    errors.new_password =
        'New password must be different from the current password';
}

if (Object.keys(errors).length > 0) {
    throw new AppError(
        'Password validation failed',
        422,
        errors
    );
}

const user = await userModel.findById(userId);

if (!user) {
    throw new AppError('User not found', 404);
}

const passwordMatches = await bcrypt.compare(
    currentPassword,
    user.password_hash
);

if (!passwordMatches) {
    throw new AppError(
        'Current password is incorrect',
        401
    );
}

const rounds = Number(
    process.env.BCRYPT_ROUNDS || 12
);

const passwordHash = await bcrypt.hash(
    newPassword,
    rounds
);

const updatedUser = await userModel.updatePassword(
    user.id,
    passwordHash
);

return sanitizeUser(updatedUser);


}

module.exports = {
register,
login,
changePassword,
sanitizeUser
};
Registration must never accept a client-submitted administrator role. New public accounts should receive the server-defined default role.

Step 14: Create the Authentication Controller

Create src/controllers/authController.js:

const authService = require('../services/authService');


async function register(req, res) {
const result = await authService.register(req.body);


res.status(201).json({
    success: true,
    message: 'Registration completed successfully',
    data: {
        user: result.user,
        token_type: 'Bearer',
        access_token: result.accessToken,
        expires_in: process.env.JWT_ACCESS_EXPIRES_IN
            || '15m'
    }
});


}

async function login(req, res) {
const result = await authService.login(req.body);


res.status(200).json({
    success: true,
    message: 'Login completed successfully',
    data: {
        user: result.user,
        token_type: 'Bearer',
        access_token: result.accessToken,
        expires_in: process.env.JWT_ACCESS_EXPIRES_IN
            || '15m'
    }
});


}

async function profile(req, res) {
res.status(200).json({
success: true,
message: 'Profile retrieved successfully',
data: {
user: req.user
}
});
}

async function changePassword(req, res) {
const user = await authService.changePassword(
req.user.id,
req.body
);


res.status(200).json({
    success: true,
    message:
        'Password changed successfully. Please sign in again.',
    data: {
        user
    }
});


}

module.exports = {
register,
login,
profile,
changePassword
};

Step 15: Create the Authentication Middleware

Create src/middleware/authenticate.js:

const userModel = require('../models/userModel');


const AppError = require('../utils/AppError');

const {
verifyAccessToken
} = require('../utils/tokenService');

async function authenticate(req, res, next) {
try {
const authorization = String(
req.headers.authorization || ''
);


    const [scheme, token] = authorization.split(' ');

    if (
        scheme !== 'Bearer'
        || !token
    ) {
        throw new AppError(
            'A valid Bearer access token is required',
            401
        );
    }

    const payload = verifyAccessToken(token);
    const userId = Number(payload.sub);

    if (
        !Number.isSafeInteger(userId)
        || userId < 1
    ) {
        throw new AppError(
            'Access token subject is invalid',
            401
        );
    }

    const user = await userModel.findById(userId);

    if (!user) {
        throw new AppError(
            'The token user no longer exists',
            401
        );
    }

    if (user.status !== 'active') {
        throw new AppError(
            'This account is not available',
            403
        );
    }

    if (
        Number(payload.version)
        !== Number(user.token_version)
    ) {
        throw new AppError(
            'This access token has been revoked',
            401
        );
    }

    req.user = {
        id: user.id,
        name: user.name,
        email: user.email,
        role: user.role,
        status: user.status,
        last_login_at: user.last_login_at,
        created_at: user.created_at,
        updated_at: user.updated_at
    };

    next();
} catch (error) {
    next(error);
}


}

module.exports = authenticate;

This middleware does more than verify the signature. It also confirms that:

  • The Authorization header uses the Bearer scheme
  • The token is correctly signed
  • The token has not expired
  • The issuer and audience are correct
  • The represented user still exists
  • The account remains active
  • The token version has not been revoked
Learn more about request-processing functions in Express.js Middleware Explained.

Step 16: Create Role Authorization Middleware

Create src/middleware/authorize.js:

const AppError = require('../utils/AppError');


function authorize(...allowedRoles) {
return function authorizationMiddleware(
req,
res,
next
) {
if (!req.user) {
return next(
new AppError(
'Authentication is required',
401
)
);
}


    if (!allowedRoles.includes(req.user.role)) {
        return next(
            new AppError(
                'You do not have permission to perform this action',
                403
            )
        );
    }

    next();
};


}

module.exports = authorize;

Step 17: Create Authentication Routes

Create src/routes/authRoutes.js:

const express = require('express');


const authController =
require('../controllers/authController');

const asyncHandler =
require('../middleware/asyncHandler');

const authenticate =
require('../middleware/authenticate');

const router = express.Router();

router.post(
'/register',
asyncHandler(authController.register)
);

router.post(
'/login',
asyncHandler(authController.login)
);

router.get(
'/me',
authenticate,
asyncHandler(authController.profile)
);

router.patch(
'/change-password',
authenticate,
asyncHandler(authController.changePassword)
);

module.exports = router;

Step 18: Create an Administrator Controller

Create src/controllers/userController.js:

const userModel = require('../models/userModel');


async function index(req, res) {
const users = await userModel.listUsers();


res.status(200).json({
    success: true,
    message: 'Users retrieved successfully',
    count: users.length,
    data: users
});


}

module.exports = {
index
};

Step 19: Create Protected User Routes

Create src/routes/userRoutes.js:

const express = require('express');


const userController =
require('../controllers/userController');

const asyncHandler =
require('../middleware/asyncHandler');

const authenticate =
require('../middleware/authenticate');

const authorize =
require('../middleware/authorize');

const router = express.Router();

router.get(
'/',
authenticate,
authorize('admin'),
asyncHandler(userController.index)
);

module.exports = router;

The route first authenticates the requester and then checks whether the current user's role is admin.

Step 20: Create 404 Middleware

Create src/middleware/notFound.js:

const AppError = require('../utils/AppError');


function notFound(req, res, next) {
next(
new AppError(
`Endpoint not found: ${req.method} ${req.originalUrl}`,
404
)
);
}

module.exports = notFound;

Step 21: Create the Error Handler

Create src/middleware/errorHandler.js:

function errorHandler(err, req, res, next) {
console.error(err);

let statusCode = Number.isInteger(err.statusCode)
    ? err.statusCode
    : 500;

let message = err.message
    || 'An unexpected server error occurred';

if (err.code === 'ER_DUP_ENTRY') {
    statusCode = 409;
    message =
        'An account with the submitted email already exists';
}

if (err.code === 'ECONNREFUSED') {
    statusCode = 503;
    message = 'The database service is unavailable';
}

const response = {
    success: false,
    message
};

if (err.details) {
    response.errors = err.details;
}

if (
    process.env.NODE_ENV !== 'production'
    && err.stack
) {
    response.stack = err.stack;
}

res.status(statusCode).json(response);


}

module.exports = errorHandler;

Step 22: Create the Express Application

Create src/app.js:

const express = require('express');


const authRoutes = require('./routes/authRoutes');
const userRoutes = require('./routes/userRoutes');

const notFound = require('./middleware/notFound');
const errorHandler = require('./middleware/errorHandler');

const app = express();

app.disable('x-powered-by');

app.use(express.json({
limit: '100kb'
}));

app.use(express.urlencoded({
extended: false,
limit: '100kb'
}));

app.get('/api/health', (req, res) => {
res.status(200).json({
success: true,
message: 'Authentication API is healthy',
timestamp: new Date().toISOString()
});
});

app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/users', userRoutes);

app.use(notFound);
app.use(errorHandler);

module.exports = app;

Step 23: Create the Server Entry Point

Create server.js:

require('dotenv').config();


const app = require('./src/app');

const {
testDatabaseConnection
} = require('./src/config/database');

const PORT = Number(process.env.PORT || 3000);

async function startServer() {
try {
await testDatabaseConnection();


    app.listen(PORT, () => {
        console.log(
            `Authentication API running at http://localhost:${PORT}`
        );
    });
} catch (error) {
    console.error(
        'Application startup failed:',
        error.message
    );

    process.exit(1);
}


}

startServer();

Step 24: Start the API

npm run dev

Expected output:

MySQL connection established


Authentication API running at http://localhost:3000

Register a User

POST http://localhost:3000/api/v1/auth/register

Request body:

{
"name": "Arafat Islam",
"email": "arafat@example.com",
"password": "A-Strong-Password-2026",
"password_confirmation": "A-Strong-Password-2026"


}

Successful response:

{
"success": true,
"message": "Registration completed successfully",
"data": {
    "user": {
        "id": 1,
        "name": "Arafat Islam",
        "email": "arafat@example.com",
        "role": "user",
        "status": "active"
    },
    "token_type": "Bearer",
    "access_token": "your.jwt.token",
    "expires_in": "15m"
}


}

Login

POST http://localhost:3000/api/v1/auth/login
{
"email": "arafat@example.com",
"password": "A-Strong-Password-2026"


}

Access the Protected Profile Route

GET http://localhost:3000/api/v1/auth/me

Add this request header:

Authorization: Bearer your.jwt.token

Successful response:

{
"success": true,
"message": "Profile retrieved successfully",
"data": {
    "user": {
        "id": 1,
        "name": "Arafat Islam",
        "email": "arafat@example.com",
        "role": "user",
        "status": "active"
    }
}


}

Change the Password

PATCH http://localhost:3000/api/v1/auth/change-password

Add the Bearer token and send:

{
"current_password": "A-Strong-Password-2026",
"new_password": "Another-Strong-Password-2026",
"new_password_confirmation": "Another-Strong-Password-2026"


}

Changing the password increments token_version, invalidating previously issued tokens for that account.

Test the Administrator Route

Promote a test user manually:

UPDATE users


SET role = 'admin'
WHERE email = '[arafat@example.com](mailto:arafat@example.com)';
Log in again after changing the role because the existing JWT contains the previous role claim.

Request:

GET http://localhost:3000/api/v1/users

Header:

Authorization: Bearer administrator.jwt.token

API Endpoint Summary

Method Endpoint Protection Purpose
POST /api/v1/auth/register Public Create a user account
POST /api/v1/auth/login Public Authenticate and issue an access token
GET /api/v1/auth/me Authenticated Retrieve the current user
PATCH /api/v1/auth/change-password Authenticated Change the current password
GET /api/v1/users Administrator List user accounts

Testing with cURL

Register

curl -X POST http://localhost:3000/api/v1/auth/register \


-H "Content-Type: application/json" 
-d "{"name":"Arafat Islam","email":"[arafat@example.com](mailto:arafat@example.com)","password":"A-Strong-Password-2026","password_confirmation":"A-Strong-Password-2026"}"

Login

curl -X POST http://localhost:3000/api/v1/auth/login \


-H "Content-Type: application/json" 
-d "{"email":"[arafat@example.com](mailto:arafat@example.com)","password":"A-Strong-Password-2026"}"

Current Profile

curl http://localhost:3000/api/v1/auth/me \


-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Where Should the Client Store the Token?

Token storage depends on the type of client application.

Client Common Approach Important Consideration
Mobile application Operating-system secure storage Avoid plain-text local files or preferences
Server-rendered website Secure, HttpOnly, SameSite cookie Include appropriate CSRF protection
Browser SPA Architecture-dependent Evaluate XSS and CSRF risks carefully
Avoid placing long-lived authentication tokens in URLs. URLs may be recorded in browser history, server logs, analytics systems, and referrer headers.

Access Tokens and Refresh Tokens

This tutorial issues a short-lived access token. A production application may also implement refresh tokens so users can obtain new access tokens without entering their passwords repeatedly.

Recommended Token Model
  • Use a short-lived access token.
  • Use a longer-lived refresh token.
  • Store only a hash of the refresh token in the database.
  • Rotate the refresh token whenever it is used.
  • Revoke the refresh-token family when reuse is detected.
  • Allow users to view and terminate active sessions.

JWT Logout Explained

A JWT access token remains valid until it expires unless the server maintains a revocation mechanism.

Practical logout options include:

  • Delete the token from the client
  • Use short access-token lifetimes
  • Increment the user's token version
  • Revoke the refresh token
  • Maintain a denylist for high-security cases

Logout from All Devices

A simple logout-all operation can increment the token version:

await userModel.incrementTokenVersion(req.user.id);

All earlier tokens containing the previous version will then fail authentication.

Security Improvements for Production

  • Use HTTPS for every authentication request.
  • Use short-lived access tokens.
  • Verify algorithm, issuer, audience, expiration, and token subject.
  • Use asynchronous password hashing.
  • Rate-limit registration and login endpoints.
  • Add delays or temporary lockouts after repeated failures.
  • Use generic login errors to reduce account enumeration.
  • Add verified-email workflows.
  • Add secure password-reset tokens.
  • Require multi-factor authentication for sensitive accounts.
  • Record security events in an audit log.
  • Never log passwords or complete tokens.
  • Keep secrets outside source control.
  • Rotate compromised signing keys.

Add Login Rate Limiting

Install a rate-limiting package:

npm install express-rate-limit

Create a login limiter:

const rateLimit = require('express-rate-limit');


const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 20,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
message:
'Too many authentication attempts. Try again later.'
}
});

app.use('/api/v1/auth', authLimiter);
A single IP-based limit may affect users sharing the same corporate or mobile network. Production limits should reflect the application's real traffic and risk model.

Common Authentication Errors

Install the package and import it correctly:
npm install jsonwebtoken


const jwt = require('jsonwebtoken');

The verification secret, key, algorithm, issuer, or audience may not match the values used when the token was issued.

The client must authenticate again or use a valid refresh-token flow to obtain a new access token.

Confirm that you are using a supported stable Node.js version. Native-module installation may also require compatible system build tools when a prebuilt binary is unavailable.

Send the token using the Bearer authorization scheme:
Authorization: Bearer YOUR_ACCESS_TOKEN
For additional troubleshooting, read Common Node.js Errors and How to Fix Them.

Authentication Best-Practices Checklist

  • Never store plain-text passwords.
  • Use asynchronous bcrypt operations.
  • Use a unique email constraint in the database.
  • Return generic errors for invalid login credentials.
  • Do not place confidential information in JWT payloads.
  • Define and verify the accepted JWT algorithm.
  • Verify issuer, audience, expiration, and subject.
  • Load the current user during protected requests.
  • Check account status on every protected request.
  • Use authorization middleware for role restrictions.
  • Apply login and registration rate limits.
  • Use HTTPS in production.
  • Implement token revocation or rotation where required.
  • Keep JWT secrets out of Git.
  • Log authentication events without logging secrets.

Frequently Asked Questions

A normally signed JWT protects token integrity but does not hide its payload. Anyone possessing the token can usually decode its header and payload.

No. Passwords, password hashes, reset tokens, private keys, and other confidential values must not be placed inside JWT payloads.

Access tokens should generally be short-lived. The appropriate lifetime depends on the application's sensitivity, client type, user experience, and refresh-token design.

It removes the client's ability to reuse that copy, but the token may remain valid until expiration unless the server has a revocation mechanism.

No. JWTs are useful for many APIs and distributed systems, but traditional server-managed sessions may be simpler and easier to revoke for some web applications.

Conclusion

You have now built a complete Express.js registration and login API using MySQL, bcrypt, and JSON Web Tokens. The application hashes passwords, validates credentials, creates short-lived access tokens, protects private routes, checks current account status, supports token revocation through versioning, and enforces administrator-only access.

This authentication system can be extended for ERP software, HRMS platforms, warehouse systems, e-commerce applications, mobile apps, dashboards, SaaS platforms, and other business applications.

The next production-focused step is implementing refresh-token rotation, secure logout, active session management, password reset, email verification, and multi-factor authentication.

About ShasTech-IT

ShasTech-IT develops secure Node.js APIs, Express.js applications, authentication systems, ERP platforms, HRMS software, warehouse management systems, POS solutions, Android applications, and custom business software.

Contact WhatsApp