A step-by-step guide to implementing JSON Web Tokens in your application with real-world security best practices
Introduction
Authentication is the gatekeeper of your application. Get it wrong, and you've handed the keys to the kingdom to anyone who asks. Get it right, and you've built a fortress that users can trust.
Today, we're building a secure JWT-based authentication system from the ground up. Whether you're protecting a REST API, a web application, or a mobile backend, this guide will show you how to implement industry-standard authentication without the headaches.
What We're Building
By the end of this tutorial, you'll have:
- ✅ A working JWT authentication system
- ✅ Refresh token rotation mechanism
- ✅ Secure password hashing
- ✅ Protection against common attack vectors
- ✅ Production-ready error handling
Tech Stack: Node.js, Express, bcryptjs, jsonwebtoken, SQLite (easily adaptable to your DB)
Part 1: Understanding JWT (The 2-Minute Version)
A JWT token looks like gibberish, but it's actually three parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Breaking it down:
- Header: Algorithm and token type
- Payload: Your actual data (user ID, email, permissions)
- Signature: The secret sauce that proves it's legit
Important: The header and payload are just Base64-encoded, not encrypted. Never put passwords or credit card numbers in JWT payload!
Part 2: Project Setup
mkdir secure-auth-system
cd secure-auth-system
npm init -y
npm install express bcryptjs jsonwebtoken dotenv sqlite3 cors
npm install --save-dev nodemon
Create your .env file:
PORT=3000
JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_REFRESH_SECRET=your-refresh-secret-key
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
NODE_ENV=development
⚠️ Security First: In production, use a strong random string (at least 32 characters) for your secrets. Never commit .env to Git!
Part 3: Database Setup
Create database.js:
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const db = new sqlite3.Database(path.join(__dirname, 'auth.db'));
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
});
module.exports = db;
Why track refresh tokens? Token revocation. When a user logs out, you can blacklist their refresh token.
Part 4: Authentication Middleware
Create middleware/auth.js:
const jwt = require('jsonwebtoken');
const verifyToken = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({
error: 'No token provided'
});
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.userId;
next();
} catch (error) {
// Different error for expired vs invalid token
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Token expired',
code: 'TOKEN_EXPIRED'
});
}
return res.status(403).json({
error: 'Invalid token'
});
}
};
module.exports = { verifyToken };
Pro tip: Return specific error codes so your frontend knows whether to refresh the token or send the user back to login.
Part 5: Core Authentication Logic
Create controllers/authController.js:
javascript
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const db = require('../database');
const SALT_ROUNDS = 10;
// Generate both access and refresh tokens
const generateTokens = (userId) => {
const accessToken = jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
);
const refreshToken = jwt.sign(
{ userId },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: process.env.JWT_REFRESH_EXPIRES_IN }
);
return { accessToken, refreshToken };
};
// Register new user
exports.register = async (req, res) => {
try {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({
error: 'Email and password required'
});
}
// Password strength check
if (password.length < 8) {
return res.status(400).json({
error: 'Password must be at least 8 characters'
});
}
// Hash password
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
// Insert user
db.run(
'INSERT INTO users (email, password) VALUES (?, ?)',
[email, hashedPassword],
function(err) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({
error: 'Email already registered'
});
}
return res.status(500).json({ error: 'Registration failed' });
}
const { accessToken, refreshToken } = generateTokens(this.lastID);
// Store refresh token
db.run(
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, datetime(?, \'+7 days\'))',
[this.lastID, refreshToken, 'now'],
(err) => {
if (err) {
return res.status(500).json({ error: 'Failed to create session' });
}
res.status(201).json({
message: 'User registered successfully',
accessToken,
refreshToken
});
}
);
}
);
} catch (error) {
console.error('Register error:', error);
res.status(500).json({ error: 'Server error' });
}
};
// Login user
exports.login = async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({
error: 'Email and password required'
});
}
// Find user
db.get(
'SELECT * FROM users WHERE email = ?',
[email],
async (err, user) => {
if (err) {
return res.status(500).json({ error: 'Database error' });
}
if (!user)
Top comments (0)