JWT (JSON Web Token) Authentication
What is a JWT?
A JWT (JSON Web Token) is a compact, self-contained string that securely transmits information between parties. JWTs are the most popular mechanism for implementing stateless API authentication.
When a user logs in, the server creates a JWT signed with a secret key and sends it to the client. The client stores this token and includes it in every subsequent request. The server can verify the token's authenticity by checking the signature — no database lookup required.
The Three Parts of a JWT
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NHVzZXIwMDEiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE2OTk5OTk5OTl9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
It is three Base64-encoded strings separated by dots: header.payload.signature
1. Header
Describes the token type and the hashing algorithm used:
{
"alg": "HS256",
"typ": "JWT"
}
HS256 is HMAC (Hash-based Message Authentication Code) with SHA-256. It uses a single shared secret key.
2. Payload
Contains the claims — the data you want to store in the token. Standard claims include iat (issued at) and exp (expiry time). You add custom claims for your application:
{
"userId": "64user001",
"email": "ngozi@example.com",
"role": "admin",
"iat": 1699999999,
"exp": 1700086399
}
Important: The payload is Base64 encoded, not encrypted. Anyone can decode and read it. Never put sensitive data like passwords or payment details in a JWT payload.
3. Signature
The signature is what makes JWTs secure. It is created by combining the encoded header and payload with your secret key:
HMACSHA256(base64(header) + "." + base64(payload), YOUR_SECRET_KEY)
If anyone tampers with the payload (for example, changing their role to "admin"), the signature will no longer match — the server rejects the token.
Setting Up JWT in a Node.js Project
Install the jsonwebtoken package:
npm install jsonwebtoken
Add your JWT secret to your .env file:
JWT_SECRET=your_very_long_random_secret_key_here
JWT_EXPIRES_IN=7d
Generate a strong secret with Node.js:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Signing a Token
const jwt = require('jsonwebtoken');
function generateToken(user) {
return jwt.sign(
{
userId: user._id,
email: user.email,
role: user.role,
},
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN } // e.g. "7d", "2h", "30m"
);
}
Verifying a Token
function verifyToken(token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return decoded; // Returns the payload if valid
} catch (error) {
// jwt.JsonWebTokenError: invalid token
// jwt.TokenExpiredError: token expired
throw new Error('Invalid or expired token');
}
}
Verify vs Decode: jwt.verify() checks the signature and expiry — use this in your middleware. jwt.decode() just decodes the payload without any verification — never use this for security checks.
Full Login and Register Flow
Register Route
// POST /auth/register
router.post('/register', async (req, res) => {
try {
const { name, email, password } = req.body;
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ message: 'Email already registered' });
}
// Hash the password before saving (covered in the next lesson)
const hashedPassword = await bcrypt.hash(password, 12);
const user = await User.create({ name, email, password: hashedPassword });
const token = generateToken(user);
res.status(201).json({
message: 'Account created successfully',
token,
user: { id: user._id, name: user.name, email: user.email },
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Login Route
// POST /auth/login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
// Find user by email
const user = await User.findOne({ email }).select('+password');
if (!user) {
return res.status(401).json({ message: 'Invalid email or password' });
}
// Compare password with stored hash
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ message: 'Invalid email or password' });
}
const token = generateToken(user);
res.json({
message: 'Login successful',
token,
user: { id: user._id, name: user.name, email: user.email, role: user.role },
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Note that we return the same error message for "user not found" and "wrong password" — this prevents attackers from using different error messages to discover which emails are registered.
Authentication Middleware
This middleware extracts and verifies the JWT from the Authorization header, then attaches the user data to req.user:
// middleware/authenticate.js
const jwt = require('jsonwebtoken');
const authenticate = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
// Token should be in format: "Bearer <token>"
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'No token provided' });
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Optionally fetch the full user from DB for up-to-date data
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ message: 'Invalid or expired token' });
}
};
module.exports = authenticate;
Protecting Routes
const authenticate = require('../middleware/authenticate');
// Public route — no token required
router.get('/posts', getAllPosts);
// Protected route — token required
router.post('/posts', authenticate, createPost);
// Admin-only route — token + role check
router.delete('/users/:id', authenticate, requireRole('admin'), deleteUser);
Token Refresh Strategy
JWTs should have a relatively short expiry (15 minutes to 24 hours) for security. To avoid forcing users to log in repeatedly, use a refresh token pattern:
- Issue a short-lived access token (e.g. 15 minutes) for API requests
- Issue a long-lived refresh token (e.g. 30 days) stored in an
HttpOnlycookie - When the access token expires, the client calls
POST /auth/refreshwith the refresh token to get a new access token
This pattern is used by major APIs including Google and Stripe.
Practice Exercise
Build a complete JWT authentication system:
- Create a
Usermodel with name, email, and password fields - Create
POST /auth/registerthat creates a user and returns a JWT - Create
POST /auth/loginthat verifies credentials and returns a JWT - Write an
authenticatemiddleware that reads theAuthorizationheader - Create a protected
GET /auth/meroute that returns the current user's data usingreq.user - Test the full flow in Postman: register, login, use the token to access
/auth/me, then try accessing/auth/mewith an invalid token
Try it yourself
Key Takeaways
- A JWT (JSON Web Token) has three parts: header, payload, and signature — separated by dots and Base64 encoded.
- The signature is what makes JWTs secure — it proves the token was created by your server and has not been tampered with.
- The payload is readable by anyone — never store passwords or sensitive data in a JWT.
- Use jwt.verify() in your middleware to validate tokens; jwt.decode() skips security checks and should not be used for authentication.
- Return the same error message for 'wrong password' and 'user not found' to prevent attackers from discovering registered emails.
Quick Quiz
1.Which part of a JWT is used to verify that the token has not been tampered with?
2.Why should you never put a user's password in a JWT payload?
3.What is the correct format for sending a JWT in an API request?
4.What is the difference between jwt.verify() and jwt.decode()?
Ready to go further?
CareerEx gives you structured 12-week training, live classes every Saturday and Sunday, real tutor feedback, and a certificate. Join the next cohort.
Join CareerEx