Node.js Authentication and Security: JWT, bcrypt, Sessions,
이 글의 핵심
Secure Node.js APIs: hash passwords with bcrypt, issue and verify JWTs, refresh tokens, express-session with Mongo store, Passport Google OAuth, Helmet, rate limits, CORS, validation, and XSS defenses.
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)
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. Secure Node.js APIs: hash passwords with bcrypt, issue and verify JWTs, refresh tokens, express-session with Mongo store…
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
- Express.js Complete Guide: Node.js Web Framework and REST
- Node.js Database Integration: MongoDB, PostgreSQL, and MySQL
- Node.js JWT Authentication Complete Guide | bcrypt
Keywords Covered in This Article (Related Search Terms)
This article covers Node.js, Authentication, JWT, bcrypt, Security, OAuth, Session.