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.
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 |
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.
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
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 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
};
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
};
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
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)';
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 |
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);
Common Authentication Errors
npm install jsonwebtoken
const jwt = require('jsonwebtoken'); Authorization: Bearer YOUR_ACCESS_TOKEN
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
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.