Node.js JWT Authentication Guide | bcrypt, Sessions & OAuth

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

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

Resources


  • C++ REST API client
  • C++ SSL/TLS
  • C++ chat server
  • C++ secure coding (OpenSSL)
  • C++ chat server (auth & rooms)
... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3