본문으로 건너뛰기
Previous
Next
Node.js JWT Authentication Complete Guide | bcrypt

Node.js JWT Authentication Complete Guide | bcrypt

Node.js JWT Authentication Complete Guide | bcrypt

이 글의 핵심

JWT authentication in Node.js: bcrypt password hashing, access and refresh tokens, Express middleware, sessions, Passport OAuth, and security headers—complete backend auth guide.

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');
    }
}

JWT structure, signing, and verification (internals)

A JWT is a JWS compact string: header.payload.signature. Each part is Base64URL JSON (not encryption). The signature is computed over the exact bytes base64url(header) + "." + base64url(payload) using the algorithm in alg (commonly HS256 with a shared secret in a single Node service, or RS256 when an authorization server signs and APIs verify with a public key / JWKS).

What jwt.verify does
Parse segments → check alg is allowed → recompute or verify the signature with your secret or IdP public key → parse payload → enforce exp (and nbf/iat if set). jwt.decode skips the signature — use it only for debugging or non-security display, never for authz decisions.

Claims
Registered claims include sub (subject), iat, exp, jti, and for OAuth/OIDC often iss and aud. Put identity and roles in claims; never put passwords or secrets in the payload. If you need logout before exp, use short access TTLs, jti + Redis denylist, or refresh-token rotation (see the refresh section below).

Production habits
Use strong random secrets (or RSA key pairs for RS256), separate secrets for access vs refresh if you issue both, and in multi-service setups prefer RS256 + JWKS so verifiers do not hold signing keys.

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

JWTSession
WhereClient (often Authorization)Server + cookie
ScaleStatelessNeeds shared store at scale
RevocationHard (use denylist or short TTL)Easy (delete session)
Payload sizeLargerSmall session id
Typical useAPIs, microservicesTraditional 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

  1. Password hashing: bcrypt/argon2—never plaintext.
  2. JWT: stateless access tokens; pair with refresh tokens for rotation.
  3. Sessions: server-controlled, easy logout.
  4. OAuth 2.0: Passport strategies for Google/GitHub.
  5. Defense in depth: Helmet, rate limits, CORS, validation, SQLi/XSS mitigations.

Auth comparison

JWTSessionOAuth
ComplexityMediumLowHigher
ScaleStrongNeeds storeStrong
RevocationHardEasyProvider-dependent
Use caseAPIsWeb appsSocial 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

  • [Node.js testing](/en/blog/nodejs-series-08-testing/
  • Node.js deployment
  • [Node.js performance](/en/blog/nodejs-series-10-performance/

Resources



자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. JWT authentication in Node.js: bcrypt password hashing, access and refresh tokens, Express middleware, sessions, Passpor… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Node.js, JWT, Authentication, bcrypt, Security, OAuth, Express 등으로 검색하시면 이 글이 도움이 됩니다.