Authentication with JWT
Most web applications need to know who is making requests. Authentication verifies user identity, while authorization determines what they can access. In this chapter, we’ll implement a complete authentication system using industry-standard practices.
Authentication vs. Authorization
| Concept | Question It Answers | Example |
|---|
| Authentication | ”Who are you?” | Login with username/password |
| Authorization | ”What can you do?” | Can this user delete posts? |
Understanding JWT (JSON Web Tokens)
Traditional session-based authentication stores user data on the server. This works, but has challenges:
- Scalability: Sessions must be shared across multiple servers
- Mobile apps: Cookies don’t work well on native apps
- Microservices: Each service needs access to session store
JWT solves these problems with stateless authentication. Instead of storing session data on the server, the server issues a signed token that the client stores and sends with each request.
How JWT Works
1. User logs in with credentials
2. Server verifies credentials
3. Server creates a signed JWT containing user info
4. Client stores the token (localStorage, cookie)
5. Client sends token with every request
6. Server verifies token signature and extracts user info
JWT Structure
A JWT consists of three parts separated by dots:
- Header: Algorithm and token type
- Payload: User data (claims) like user ID, role, expiration
- Signature: Ensures the token hasn’t been tampered with
Never store sensitive information (passwords, credit cards) in the JWT payload. The payload is Base64-encoded, not encrypted—anyone can decode and read it.
Prerequisites
Install the necessary packages:
npm install jsonwebtoken bcryptjs
- jsonwebtoken: To sign and verify tokens.
- bcryptjs: To hash passwords securely.
1. User Registration (Hashing Passwords)
Never store passwords in plain text. Use bcryptjs to hash them.
const bcrypt = require('bcryptjs');
// ... inside your register route
const { username, password } = req.body;
// Check if user exists...
// Hash password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// Save user with hashedPassword...
2. User Login (Comparing Passwords)
When a user logs in, compare the provided password with the hashed password in the database.
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(400).json({ msg: 'Invalid credentials' });
}
3. Generating a JWT
If the password is valid, generate a token and send it to the client.
const jwt = require('jsonwebtoken');
const payload = {
user: {
id: user.id
}
};
jwt.sign(
payload,
process.env.JWT_SECRET,
{ expiresIn: '1h' },
(err, token) => {
if (err) throw err;
res.json({ token });
}
);
Note: Store JWT_SECRET in your .env file.
4. Protecting Routes (Middleware)
Create middleware to verify the token on protected routes.
middleware/auth.js
const jwt = require('jsonwebtoken');
module.exports = function(req, res, next) {
// Get token from header
const token = req.header('x-auth-token');
// Check if not token
if (!token) {
return res.status(401).json({ msg: 'No token, authorization denied' });
}
// Verify token
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded.user;
next();
} catch (err) {
res.status(401).json({ msg: 'Token is not valid' });
}
};
5. Using the Middleware
const auth = require('./middleware/auth');
// Protected route
app.get('/api/auth/user', auth, async (req, res) => {
try {
// Fetch user from DB using req.user.id
// ...
res.json(user);
} catch (err) {
res.status(500).send('Server Error');
}
});
Summary
- bcryptjs: Use
hash() to encrypt passwords and compare() to validate
- JWT: Use
sign() to create tokens and verify() to validate
- Middleware: Create custom middleware to protect private routes
- Stateless: The server doesn’t store session data; the token contains necessary info
Access Tokens vs Refresh Tokens
For production applications, implement a dual-token system:
| Token Type | Lifetime | Purpose |
|---|
| Access Token | 15 min - 1 hour | Authenticate API requests |
| Refresh Token | 7-30 days | Get new access tokens |
// Generate both tokens
const generateTokens = (userId) => {
const accessToken = jwt.sign(
{ userId },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
};
// Login endpoint
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const { accessToken, refreshToken } = generateTokens(user._id);
// Store refresh token in database or Redis
await RefreshToken.create({ token: refreshToken, userId: user._id });
// Send refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({ accessToken });
});
// Refresh endpoint
app.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
// Check if refresh token exists in database
const storedToken = await RefreshToken.findOne({ token: refreshToken });
if (!storedToken) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Generate new access token
const accessToken = jwt.sign(
{ userId: decoded.userId },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken });
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
// Logout endpoint
app.post('/api/auth/logout', async (req, res) => {
const { refreshToken } = req.cookies;
// Remove from database
await RefreshToken.deleteOne({ token: refreshToken });
// Clear cookie
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
Role-Based Access Control (RBAC)
// User model with roles
const UserSchema = new mongoose.Schema({
email: String,
password: String,
role: {
type: String,
enum: ['user', 'admin', 'moderator'],
default: 'user'
}
});
// Role middleware
const authorize = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
error: 'Not authorized to access this resource'
});
}
next();
};
};
// Usage
app.get('/api/admin/users', auth, authorize('admin'), (req, res) => {
// Only admins can access
});
app.delete('/api/posts/:id', auth, authorize('admin', 'moderator'), (req, res) => {
// Admins and moderators can delete
});
OAuth 2.0 with Passport.js
npm install passport passport-google-oauth20
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback'
},
async (accessToken, refreshToken, profile, done) => {
try {
let user = await User.findOne({ googleId: profile.id });
if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
avatar: profile.photos[0].value
});
}
return done(null, user);
} catch (error) {
return done(error, null);
}
}
));
// Routes
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
app.get('/auth/google/callback',
passport.authenticate('google', { session: false }),
(req, res) => {
const { accessToken, refreshToken } = generateTokens(req.user._id);
res.redirect(`/auth-success?token=${accessToken}`);
}
);
Password Reset Flow
const crypto = require('crypto');
const nodemailer = require('nodemailer');
// Request password reset
app.post('/api/auth/forgot-password', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
if (!user) {
// Don't reveal if email exists
return res.json({ message: 'If email exists, reset link sent' });
}
// Generate reset token
const resetToken = crypto.randomBytes(32).toString('hex');
const hashedToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
user.resetPasswordToken = hashedToken;
user.resetPasswordExpires = Date.now() + 3600000; // 1 hour
await user.save();
// Send email
const resetUrl = `${process.env.FRONTEND_URL}/reset-password/${resetToken}`;
await transporter.sendMail({
to: user.email,
subject: 'Password Reset',
html: `Click <a href="${resetUrl}">here</a> to reset your password.`
});
res.json({ message: 'If email exists, reset link sent' });
});
// Reset password
app.post('/api/auth/reset-password/:token', async (req, res) => {
const hashedToken = crypto
.createHash('sha256')
.update(req.params.token)
.digest('hex');
const user = await User.findOne({
resetPasswordToken: hashedToken,
resetPasswordExpires: { $gt: Date.now() }
});
if (!user) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
user.password = await bcrypt.hash(req.body.password, 10);
user.resetPasswordToken = undefined;
user.resetPasswordExpires = undefined;
await user.save();
res.json({ message: 'Password reset successful' });
});
Security Best Practices
- Never store JWTs in localStorage - vulnerable to XSS attacks
- Use httpOnly cookies for refresh tokens
- Implement token blacklisting for logout
- Use short expiration for access tokens
- Rotate refresh tokens on each use
- Hash passwords with bcrypt (cost factor 10+)
- Validate password strength on registration