Node.js JWT Authentication Guide | bcrypt, Sessions & OAuth
이 글의 핵심
End-to-end JWT authentication for Node.js APIs: hash passwords, issue and verify tokens, refresh flows, optional sessions and OAuth, plus hardening patterns for production.
Introduction
Authentication vs authorization
Authentication: “Who are you?” (identity)
Authorization: “What may you do?” (permissions, e.g. RBAC)
Common patterns:
- ✅ JWT: Stateless bearer tokens
- ✅ Sessions: Server-side state with a cookie
- ✅ OAuth 2.0: Social login (Google, GitHub)
- ✅ API keys: Simple service-to-service auth
1. Password hashing (bcrypt)
npm install bcrypt
const bcrypt = require('bcrypt');
async function hashPassword(password) {
const saltRounds = 10;
return bcrypt.hash(password, saltRounds);
}
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
Registration route
app.post('/auth/register', async (req, res) => {
try {
const { email, password, name } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ error: 'All fields are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ error: 'Email already registered' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await User.create({
email,
password: hashedPassword,
name
});
const { password: _, ...safe } = user.toObject();
res.status(201).json({ message: 'Registered', user: safe });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
2. JWT
npm install jsonwebtoken
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'change-me';
function generateToken(payload) {
return jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
}
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (err) {
if (err.name === 'TokenExpiredError') {
throw new Error('Token expired');
}
throw new Error('Invalid token');
}
}
Login
app.post('/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const ok = await bcrypt.compare(password, user.password);
if (!ok) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const token = jwt.sign(
{ id: user._id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({
message: 'Login successful',
token,
user: {
id: user._id,
email: user.email,
name: user.name,
role: user.role
}
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
Auth middleware
async function authenticate(req, res, next) {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.substring(7);
const decoded = jwt.verify(token, JWT_SECRET);
const user = await User.findById(decoded.id).select('-password');
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
res.status(401).json({ error: 'Invalid token' });
}
}
module.exports = { authenticate };
Role guard
function authorize(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
module.exports = { authorize };
const { authenticate } = require('./middlewares/auth');
const { authorize } = require('./middlewares/authorize');
app.delete('/api/users/:id', authenticate, authorize('admin'), async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.status(204).send();
});
app.get('/api/reports', authenticate, authorize('admin', 'manager'), (req, res) => {
res.json({ reports: [] });
});
3. Refresh tokens
Model and routes
// models/RefreshToken.js
const mongoose = require('mongoose');
const refreshTokenSchema = new mongoose.Schema({
token: { type: String, required: true, unique: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
expiresAt: { type: Date, required: true },
createdAt: { type: Date, default: Date.now }
});
refreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
module.exports = mongoose.model('RefreshToken', refreshTokenSchema);
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const RefreshToken = require('./models/RefreshToken');
const JWT_SECRET = process.env.JWT_SECRET;
function generateAccessToken(user) {
return jwt.sign(
{ id: user._id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '15m' }
);
}
async function generateRefreshToken(user) {
const token = crypto.randomBytes(40).toString('hex');
await RefreshToken.create({
token,
user: user._id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
return token;
}
app.post('/auth/login', async (req, res) => {
try {
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 email or password' });
}
const accessToken = generateAccessToken(user);
const refreshToken = await generateRefreshToken(user);
res.json({ accessToken, refreshToken, expiresIn: 900 });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/auth/refresh', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token required' });
}
const tokenDoc = await RefreshToken.findOne({ token: refreshToken }).populate('user');
if (!tokenDoc) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
if (tokenDoc.expiresAt < new Date()) {
await RefreshToken.deleteOne({ token: refreshToken });
return res.status(401).json({ error: 'Refresh token expired' });
}
const accessToken = generateAccessToken(tokenDoc.user);
res.json({ accessToken, expiresIn: 900 });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/auth/logout', async (req, res) => {
try {
const { refreshToken } = req.body;
if (refreshToken) {
await RefreshToken.deleteOne({ token: refreshToken });
}
res.json({ message: 'Logged out' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
4. Sessions
npm install express-session connect-mongo
const express = require('express');
const session = require('express-session');
const MongoStore = require('connect-mongo');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET || 'your-secret-key',
resave: false,
saveUninitialized: false,
store: MongoStore.create({
mongoUrl: 'mongodb://localhost:27017/mydb',
ttl: 24 * 60 * 60
}),
cookie: {
maxAge: 24 * 60 * 60 * 1000,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
}
}));
app.post('/auth/login', async (req, res) => {
try {
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 email or password' });
}
req.session.userId = user._id;
req.session.email = user.email;
req.session.role = user.role;
res.json({
message: 'Login successful',
user: { id: user._id, email: user.email, name: user.name }
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
});
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Login required' });
}
next();
}
app.get('/api/profile', requireAuth, async (req, res) => {
try {
const user = await User.findById(req.session.userId).select('-password');
res.json({ user });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
5. OAuth 2.0 (Passport)
npm install passport passport-google-oauth20 passport-github2
// config/passport.js
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const User = require('../models/User');
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
});
}
done(null, user);
} catch (err) {
done(err, null);
}
}));
passport.serializeUser((user, done) => done(null, user._id));
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id);
done(null, user);
} catch (err) {
done(err, null);
}
});
module.exports = passport;
const express = require('express');
const session = require('express-session');
const passport = require('./config/passport');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false
}));
app.use(passport.initialize());
app.use(passport.session());
app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => res.redirect('/dashboard')
);
app.get('/auth/logout', (req, res) => {
req.logout((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.redirect('/');
});
});
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) return next();
res.redirect('/login');
}
app.get('/dashboard', ensureAuthenticated, (req, res) => {
res.json({ user: req.user });
});
6. Security best practices
Helmet
npm install helmet
const helmet = require('helmet');
app.use(helmet());
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
}));
Rate limiting
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: 'Too many requests from this IP, please try again later.'
});
app.use('/api/', limiter);
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true
});
app.post('/auth/login', loginLimiter, async (req, res) => { /* ... */ });
CORS
npm install cors
const cors = require('cors');
app.use(cors());
app.use(cors({
origin: 'https://yourdomain.com',
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(cors({
origin: (origin, callback) => {
const allowedOrigins = ['https://yourdomain.com', 'https://admin.yourdomain.com'];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true
}));
Input validation
npm install express-validator
const { body, validationResult } = require('express-validator');
app.post('/auth/register',
body('email').isEmail().withMessage('Valid email required').normalizeEmail(),
body('password')
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
.matches(/[A-Z]/).withMessage('Must include an uppercase letter')
.matches(/[a-z]/).withMessage('Must include a lowercase letter')
.matches(/[0-9]/).withMessage('Must include a digit')
.matches(/[@$!%*?&#]/).withMessage('Must include a special character'),
body('name')
.trim()
.notEmpty().withMessage('Name is required')
.isLength({ min: 2, max: 50 }).withMessage('Name must be 2–50 characters'),
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
/* register */
}
);
SQL injection
async function vulnerable(email) {
const query = `SELECT * FROM users WHERE email = '${email}'`;
const [rows] = await pool.query(query);
return rows;
}
async function safe(email) {
const [rows] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
return rows;
}
async function safest(email) {
return User.findOne({ email });
}
XSS
npm install xss
const xss = require('xss');
app.post('/api/posts', async (req, res) => {
const { title, content } = req.body;
const post = await Post.create({
title: xss(title),
content: xss(content)
});
res.status(201).json(post);
});
7. Full example: User model and auth routes
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, required: true, minlength: 8 },
name: { type: String, required: true },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
isVerified: { type: Boolean, default: false },
verificationToken: String,
resetPasswordToken: String,
resetPasswordExpires: Date,
lastLogin: Date
}, { timestamps: true });
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
userSchema.methods.comparePassword = async function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
// routes/auth.js
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const User = require('../models/User');
const { sendEmail } = require('../utils/email');
router.post('/register', async (req, res) => {
try {
const { email, password, name } = req.body;
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ error: 'Email already registered' });
}
const verificationToken = crypto.randomBytes(32).toString('hex');
const user = await User.create({ email, password, name, verificationToken });
const verificationUrl = `${req.protocol}://${req.get('host')}/auth/verify/${verificationToken}`;
await sendEmail({
to: email,
subject: 'Verify your email',
text: `Click to verify: ${verificationUrl}`
});
res.status(201).json({
message: 'Registered. Please check your email.',
user: { id: user._id, email: user.email, name: user.name }
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/verify/:token', async (req, res) => {
try {
const user = await User.findOne({ verificationToken: req.params.token });
if (!user) {
return res.status(400).json({ error: 'Invalid token' });
}
user.isVerified = true;
user.verificationToken = undefined;
await user.save();
res.json({ message: 'Email verified' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/forgot-password', async (req, res) => {
try {
const { email } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.json({ message: 'If an account exists, you will receive an email.' });
}
const resetToken = crypto.randomBytes(32).toString('hex');
user.resetPasswordToken = resetToken;
user.resetPasswordExpires = Date.now() + 3600000;
await user.save();
const resetUrl = `${req.protocol}://${req.get('host')}/auth/reset-password/${resetToken}`;
await sendEmail({
to: email,
subject: 'Password reset',
text: `Reset link: ${resetUrl}`
});
res.json({ message: 'If an account exists, you will receive an email.' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/reset-password/:token', async (req, res) => {
try {
const { password } = req.body;
const user = await User.findOne({
resetPasswordToken: req.params.token,
resetPasswordExpires: { $gt: Date.now() }
});
if (!user) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
user.password = password;
user.resetPasswordToken = undefined;
user.resetPasswordExpires = undefined;
await user.save();
res.json({ message: 'Password has been reset' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
8. Common pitfalls
JWT invalidation
JWTs cannot be revoked server-side by default. Use a denylist (Redis or in-memory set for small apps) checked before jwt.verify, or prefer short-lived access tokens plus refresh rotation.
const tokenBlacklist = new Set();
app.post('/auth/logout', authenticate, (req, res) => {
const token = req.headers.authorization.substring(7);
tokenBlacklist.add(token);
res.json({ message: 'Logged out' });
});
Session store in memory
Do not use the default MemoryStore in production—use connect-mongo, connect-redis, etc.
Plaintext passwords
Never store raw passwords. Always hash with bcrypt/argon2 before persisting.
9. Practical tips
JWT vs session
| JWT | Session | |
|---|---|---|
| Where | Client (often Authorization) | Server + cookie |
| Scale | Stateless | Needs shared store at scale |
| Revocation | Hard (use denylist or short TTL) | Easy (delete session) |
| Payload size | Larger | Small session id |
| Typical use | APIs, microservices | Traditional web apps |
Password policy helper
function validatePassword(password) {
const errors = [];
if (password.length < 8) errors.push('At least 8 characters');
if (!/[A-Z]/.test(password)) errors.push('Include an uppercase letter');
if (!/[a-z]/.test(password)) errors.push('Include a lowercase letter');
if (!/[0-9]/.test(password)) errors.push('Include a digit');
if (!/[@$!%*?&#]/.test(password)) errors.push('Include a special character');
return errors;
}
Environment variables
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
REFRESH_SECRET=your-refresh-token-secret
SESSION_SECRET=your-session-secret
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
require('dotenv').config();
const config = {
jwtSecret: process.env.JWT_SECRET,
refreshSecret: process.env.REFRESH_SECRET,
sessionSecret: process.env.SESSION_SECRET
};
if (!config.jwtSecret || !config.refreshSecret) {
throw new Error('JWT_SECRET and REFRESH_SECRET are required');
}
module.exports = config;
Summary
Takeaways
- Password hashing: bcrypt/argon2—never plaintext.
- JWT: stateless access tokens; pair with refresh tokens for rotation.
- Sessions: server-controlled, easy logout.
- OAuth 2.0: Passport strategies for Google/GitHub.
- Defense in depth: Helmet, rate limits, CORS, validation, SQLi/XSS mitigations.
Auth comparison
| JWT | Session | OAuth | |
|---|---|---|---|
| Complexity | Medium | Low | Higher |
| Scale | Strong | Needs store | Strong |
| Revocation | Hard | Easy | Provider-dependent |
| Use case | APIs | Web apps | Social login |
Security checklist
- Password hashing (bcrypt, argon2)
- HTTPS in production
- Strong, rotated secrets
- Rate limiting on auth routes
- CORS locked down
- Helmet (or equivalent headers)
- Input validation
- Parameterized SQL / ORM
- XSS sanitization where needed
- CSRF protection for cookie-based web forms
Next steps
Resources
Related posts
- C++ REST API client
- C++ SSL/TLS
- C++ chat server
- C++ secure coding (OpenSSL)
- C++ chat server (auth & rooms)