Node.js 인증과 보안 | JWT, bcrypt, 세션, OAuth

이 글의 핵심

비밀번호를 평문이나 단순 해시로 저장하면 유출 시 곧바로 계정이 뚫립니다. 솔트와 비용 인자를 가진 bcrypt를 쓰는 이유, 세션과 JWT 중 무엇을 고를지, 토큰 만료와 갱신을 어떻게 설계할지를 짚고, SQL Injection과 XSS 방어까지 포함해 Node.js 서비스의 인증·보안 기본선을 정리합니다.

들어가며

인증 vs 인가

HTTP는 상태가 없어서(stateless) 매 요청마다 “누구인지”를 다시 증명해야 합니다. 그때 세션 쿠키나 JWT로 “이미 로그인했다”는 정보를 실어 보내고, 비밀번호는 bcrypt 등으로 해시만 저장합니다. 토큰은 출입증에 가깝으며, 권한(관리자만 삭제 등)은 그 토큰 안의 역할·스코프로 인가 단계에서 나눕니다.

인증 (Authentication):

  • “당신은 누구인가?” (신원 확인)

  • 로그인, 회원가입 인가 (Authorization):

  • “당신은 무엇을 할 수 있는가?” (권한 확인)

  • 역할 기반 접근 제어 (RBAC) 인증 방식:

  • JWT (JSON Web Token): Stateless, 확장성 좋음

  • 세션 (Session): Stateful, 서버에서 제어

  • OAuth 2.0: 소셜 로그인 (Google, GitHub)

  • API Key: 간단한 API 인증

이 목록은 서로 배타적인 선택지가 아닙니다. 실제 서비스는 “비밀번호 해싱 + 세션 쿠키”나 “OAuth로 신원 확인 + 자체 JWT 발급”처럼 여러 방식을 조합합니다. 비밀번호 해싱은 어떤 방식을 쓰든 필요한 공통 기반이고, JWT와 세션은 “로그인 이후 매 요청에서 사용자를 어떻게 다시 알아볼 것인가”라는 같은 질문에 대한 서로 다른 답입니다. 아래 절들은 이 순서대로 쌓아 올립니다.


비밀번호 해싱 (bcrypt)

설치

npm install bcrypt

기본 사용법

const bcrypt = require('bcrypt');
// 비밀번호 해싱
async function hashPassword(password) {
    const saltRounds = 10;  // 해싱 강도 (10~12 권장)
    const hash = await bcrypt.hash(password, saltRounds);
    return hash;
}
// 비밀번호 확인
async function verifyPassword(password, hash) {
    const isValid = await bcrypt.compare(password, hash);
    return isValid;
}
// 사용
async function main() {
    const password = 'mypassword123';
    
    // 해싱
    const hash = await hashPassword(password);
    console.log('해시:', hash);
    // $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
    
    // 확인
    const isValid = await verifyPassword(password, hash);
    console.log('비밀번호 일치:', isValid);  // true
    
    const isInvalid = await verifyPassword('wrongpassword', hash);
    console.log('잘못된 비밀번호:', isInvalid);  // false
}
main();

출력된 해시 $2b$10$...에는 알고리즘 버전(2b), 비용 인자(10), 22자짜리 솔트, 실제 해시값이 모두 들어 있습니다. 그래서 솔트를 별도 컬럼에 저장할 필요가 없고, bcrypt.compare는 저장된 문자열에서 솔트와 비용을 꺼내 같은 계산을 다시 합니다. 같은 비밀번호를 두 번 해싱하면 매번 다른 문자열이 나오는 것도 솔트가 무작위이기 때문이며, 이 덕분에 레인보우 테이블이나 “같은 해시를 가진 사용자 묶기”가 통하지 않습니다.

SHA-256 같은 범용 해시가 아니라 bcrypt를 쓰는 이유는 일부러 느리게 만들 수 있어서입니다. 범용 해시는 GPU에서 초당 엄청난 횟수로 계산되므로 DB가 유출되면 흔한 비밀번호는 금방 역산됩니다. bcrypt는 비용 인자를 1 올릴 때마다 계산량이 두 배가 되므로, 하드웨어가 빨라지면 비용만 올려 대응할 수 있습니다. 새로 시작하는 프로젝트라면 메모리까지 많이 쓰게 만들어 GPU 공격을 더 어렵게 하는 argon2id도 좋은 선택입니다.

bcrypt에는 알아 둘 함정이 두 가지 있습니다. 첫째, 입력의 앞 72바이트만 사용합니다. UTF-8 한글은 한 글자가 3바이트라서 긴 패스프레이즈라면 뒷부분이 무시될 수 있고, 비밀번호 앞에 긴 접두사를 붙이는 방식의 “페퍼” 구현은 사실상 비밀번호를 잘라 먹습니다. 둘째, bcrypt.hashSync/compareSync는 이벤트 루프를 막습니다. 비용 1012면 해시 한 번에 수십수백 밀리초가 걸리므로, 서버 코드에서는 반드시 위처럼 비동기 버전을 써야 합니다. bcrypt 패키지는 네이티브 애드온이라 Alpine 이미지나 Node 버전 변경 후 설치 단계에서 빌드 오류가 나는 일이 잦은데, 이때는 순수 JavaScript 구현인 bcryptjs로 바꾸는 것도 방법입니다(대신 더 느립니다).

회원가입 구현

const express = require('express');
const bcrypt = require('bcrypt');
const User = require('./models/User');
const app = express();
app.use(express.json());
app.post('/auth/register', async (req, res) => {
    try {
        const { email, password, name } = req.body;
        
        // 유효성 검사
        if (!email || !password || !name) {
            return res.status(400).json({ error: '모든 필드가 필요합니다' });
        }
        
        if (password.length < 8) {
            return res.status(400).json({ error: '비밀번호는 8자 이상이어야 합니다' });
        }
        
        // 중복 확인
        const existingUser = await User.findOne({ email });
        if (existingUser) {
            return res.status(400).json({ error: '이미 존재하는 이메일입니다' });
        }
        
        // 비밀번호 해싱
        const hashedPassword = await bcrypt.hash(password, 10);
        
        // 사용자 생성
        const user = await User.create({
            email,
            password: hashedPassword,
            name
        });
        
        // 비밀번호 제외하고 응답
        const { password: _, ...userWithoutPassword } = user.toObject();
        
        res.status(201).json({
            message: '회원가입 성공',
            user: userWithoutPassword
        });
        
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

이 핸들러에는 운영에서 문제가 되는 부분이 두 곳 있습니다. findOne으로 중복을 확인한 뒤 create하는 방식은 두 요청이 동시에 들어오면 둘 다 “없음”을 보고 통과할 수 있습니다. 최종 방어선은 email 필드의 unique 인덱스이고, 이때 MongoDB는 E11000 duplicate key error를 던지므로 catch에서 이 코드를 409 등으로 바꿔 주는 처리가 필요합니다. 또 err.message를 그대로 응답에 싣으면 DB 오류 메시지나 내부 경로가 클라이언트에 노출됩니다. 예제에서는 편의상 그렇게 했지만, 운영 코드에서는 서버 로그에만 남기고 사용자에게는 일반적인 메시지를 보내는 것이 맞습니다.


JWT (JSON Web Token)

설치

npm install jsonwebtoken

JWT 생성 및 검증

const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
// 토큰 생성
function generateToken(payload) {
    return jwt.sign(
        payload,
        JWT_SECRET,
        { expiresIn: '1h' }  // 1시간 후 만료
    );
}
// 토큰 검증
function verifyToken(token) {
    try {
        const decoded = jwt.verify(token, JWT_SECRET);
        return decoded;
    } catch (err) {
        if (err.name === 'TokenExpiredError') {
            throw new Error('토큰이 만료되었습니다');
        }
        throw new Error('유효하지 않은 토큰입니다');
    }
}
// 사용
const token = generateToken({ id: 123, email: '[email protected]' });
console.log('토큰:', token);
const decoded = verifyToken(token);
console.log('디코딩:', decoded);
// { id: 123, email: '[email protected]', iat: 1234567890, exp: 1234571490 }

JWT는 헤더.페이로드.서명 세 부분을 Base64URL로 인코딩한 문자열입니다. 여기서 중요한 점은 페이로드가 암호화되지 않는다는 것입니다. jwt.io에 붙여 넣으면 누구나 내용을 볼 수 있으므로 비밀번호, 주민번호 같은 민감 정보는 절대 넣지 말고, 식별자와 역할 정도만 담아야 합니다. 서명은 “서버가 발급한 뒤 아무도 내용을 바꾸지 않았다”는 것만 보장합니다.

process.env.JWT_SECRET || 'your-secret-key' 같은 기본값은 예제용일 뿐 운영에서는 위험합니다. 환경 변수 설정을 빠뜨린 채 배포해도 서버가 조용히 뜨고, 누구나 아는 비밀키로 서명된 토큰을 받아 주게 됩니다. 뒤의 “환경 변수 관리” 절처럼 값이 없으면 시작 단계에서 예외를 던지게 하는 편이 안전합니다. 검증할 때는 jwt.verify(token, secret, { algorithms: ['HS256'] })처럼 허용 알고리즘을 명시해 두면, 헤더의 alg 값을 조작하는 공격 유형을 원천적으로 막을 수 있습니다.

jwt.verify가 던지는 오류는 이름으로 구분됩니다. 만료는 TokenExpiredError: jwt expired, 서명 불일치는 JsonWebTokenError: invalid signature, 형식이 깨진 토큰은 JsonWebTokenError: jwt malformed입니다. 클라이언트가 “토큰 갱신을 시도할지, 다시 로그인시킬지”를 판단할 수 있도록 만료만 별도 코드로 구분해 주는 것이 좋습니다.

로그인 구현

const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require('./models/User');
const app = express();
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
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: '이메일 또는 비밀번호가 잘못되었습니다' });
        }
        
        // 비밀번호 확인
        const isValid = await bcrypt.compare(password, user.password);
        
        if (!isValid) {
            return res.status(401).json({ error: '이메일 또는 비밀번호가 잘못되었습니다' });
        }
        
        // JWT 토큰 생성
        const token = jwt.sign(
            { id: user._id, email: user.email, role: user.role },
            JWT_SECRET,
            { expiresIn: '1h' }
        );
        
        res.json({
            message: '로그인 성공',
            token,
            user: {
                id: user._id,
                email: user.email,
                name: user.name,
                role: user.role
            }
        });
        
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

사용자가 없을 때와 비밀번호가 틀렸을 때 같은 메시지를 돌려주는 것은 의도된 설계입니다. 메시지가 다르면 공격자가 가입된 이메일 목록을 수집할 수 있습니다. 다만 메시지만 같아서는 부족한 경우가 있습니다. 사용자가 없으면 bcrypt 비교를 건너뛰어 응답이 훨씬 빨리 오기 때문에, 응답 시간만으로도 계정 존재 여부를 추측할 수 있습니다. 이를 막으려면 사용자가 없을 때도 미리 만들어 둔 더미 해시와 bcrypt.compare를 한 번 수행해 소요 시간을 맞춥니다.

인증 미들웨어

// middlewares/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
async function authenticate(req, res, next) {
    try {
        // 헤더에서 토큰 추출
        const authHeader = req.headers.authorization;
        
        if (!authHeader || !authHeader.startsWith('Bearer ')) {
            return res.status(401).json({ error: '인증 토큰이 필요합니다' });
        }
        
        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: '사용자를 찾을 수 없습니다' });
        }
        
        // req에 사용자 정보 추가
        req.user = user;
        next();
        
    } catch (err) {
        if (err.name === 'TokenExpiredError') {
            return res.status(401).json({ error: '토큰이 만료되었습니다' });
        }
        res.status(401).json({ error: '유효하지 않은 토큰입니다' });
    }
}
module.exports = { authenticate };

이 미들웨어는 토큰 검증 후 User.findById로 DB를 한 번 더 조회합니다. JWT의 “DB 조회 없이 검증”이라는 장점을 일부 포기하는 셈이지만, 대신 삭제되거나 정지된 사용자, 역할이 바뀐 사용자를 즉시 반영할 수 있습니다. 토큰에 담긴 role만 믿으면 관리자 권한을 회수해도 토큰이 만료될 때까지 관리자로 남습니다. 요청량이 많아 조회 비용이 부담된다면 Access Token 만료를 짧게(5~15분) 두고 조회를 생략하는 식으로 트레이드오프를 조정합니다.

// 사용
const { authenticate } = require('./middlewares/auth');
app.get('/api/profile', authenticate, (req, res) => {
    res.json({ user: req.user });
});

권한 확인 미들웨어

// middlewares/authorize.js
function authorize(...roles) {
    return (req, res, next) => {
        if (!req.user) {
            return res.status(401).json({ error: '인증이 필요합니다' });
        }
        
        if (!roles.includes(req.user.role)) {
            return res.status(403).json({ error: '권한이 없습니다' });
        }
        
        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: [] });
    }
);

401과 403을 구분하는 것도 중요합니다. 401은 “누구인지 모른다(다시 로그인하라)”, 403은 “누구인지는 알지만 권한이 없다”는 뜻입니다. 프론트엔드가 401을 받으면 토큰 갱신이나 로그인 화면 이동을 시도하므로, 권한 부족에 401을 돌려주면 갱신과 재요청이 무한히 반복되는 버그가 생기기 쉽습니다.


Refresh Token

구현

// 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);
// auth.js
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const RefreshToken = require('./models/RefreshToken');
const JWT_SECRET = process.env.JWT_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;
// Access Token 생성 (짧은 유효기간)
function generateAccessToken(user) {
    return jwt.sign(
        { id: user._id, email: user.email, role: user.role },
        JWT_SECRET,
        { expiresIn: '15m' }  // 15분
    );
}
// Refresh Token 생성 (긴 유효기간)
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)  // 7일
    });
    
    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: '이메일 또는 비밀번호가 잘못되었습니다' });
        }
        
        const accessToken = generateAccessToken(user);
        const refreshToken = await generateRefreshToken(user);
        
        res.json({
            accessToken,
            refreshToken,
            expiresIn: 900  // 15분 (초 단위)
        });
    } 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이 필요합니다' });
        }
        
        // Refresh Token 확인
        const tokenDoc = await RefreshToken.findOne({ token: refreshToken })
            .populate('user');
        
        if (!tokenDoc) {
            return res.status(401).json({ error: '유효하지 않은 Refresh Token' });
        }
        
        if (tokenDoc.expiresAt < new Date()) {
            await RefreshToken.deleteOne({ token: refreshToken });
            return res.status(401).json({ error: 'Refresh Token이 만료되었습니다' });
        }
        
        // 새 Access Token 발급
        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: '로그아웃 성공' });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

Refresh Token을 JWT가 아니라 crypto.randomBytes로 만든 불투명한 문자열로 두고 DB에 저장한 것은, 이 토큰만큼은 서버가 폐기할 수 있어야 하기 때문입니다. Access Token은 짧게 살고 검증이 빠르며, Refresh Token은 길게 살지만 매번 저장소를 확인합니다. 로그아웃은 Refresh Token 문서를 지우는 것으로 처리하고, 이미 발급된 Access Token은 최대 15분 뒤에 자연히 만료됩니다. (그래서 위 코드의 REFRESH_SECRET은 이 구현에서는 실제로 쓰이지 않습니다. Refresh Token도 JWT로 만들 때만 필요합니다.)

이 기본 구현에는 보완할 점이 있습니다. /auth/refresh가 같은 Refresh Token을 7일 내내 재사용하게 두므로, 토큰이 한 번 탈취되면 공격자도 7일간 Access Token을 받아 갈 수 있습니다. 갱신할 때마다 기존 문서를 지우고 새 Refresh Token을 발급하는 rotation을 적용하면, 폐기된 토큰이 다시 들어오는 순간을 탈취 신호로 보고 해당 사용자의 모든 토큰을 지울 수 있습니다. 또 DB에는 토큰 원문 대신 SHA-256 해시를 저장해 두면 DB가 유출돼도 토큰을 바로 쓸 수 없습니다. 참고로 MongoDB TTL 인덱스는 백그라운드 작업이 약 60초 주기로 돌기 때문에 만료 직후 문서가 잠깐 남아 있을 수 있습니다. 코드에서 expiresAt을 다시 비교하는 이유가 이것입니다.

Refresh Token을 클라이언트 어디에 보관할지도 결정해야 합니다. localStorage에 두면 XSS 한 번으로 스크립트가 토큰을 읽어 갈 수 있습니다. 브라우저 클라이언트라면 httpOnly, Secure, SameSite 속성을 준 쿠키에 Refresh Token을 두고 /auth/refresh 경로로만 전송되게 path를 제한하는 방식이 흔히 쓰입니다.


세션 (Session)

설치

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  // 1일 (초 단위)
    }),
    cookie: {
        maxAge: 24 * 60 * 60 * 1000,  // 1일 (밀리초)
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',  // HTTPS에서만
        sameSite: 'strict'
    }
}));

resave: false는 세션이 바뀌지 않았으면 스토어에 다시 쓰지 않게 하고, saveUninitialized: false는 로그인하지 않은 방문자마다 빈 세션을 만들지 않게 합니다. 둘 다 스토어 부하를 줄이는 설정이며, 특히 후자는 봇 트래픽이 많을 때 세션 컬렉션이 불어나는 것을 막습니다.

secure: true를 켰는데 로그인 후에도 세션이 유지되지 않는다면, Nginx나 로드밸런서 뒤에서 Express가 요청을 HTTP로 인식하고 있을 가능성이 큽니다. 이 경우 express-session은 Secure 쿠키를 아예 내려보내지 않으므로 app.set('trust proxy', 1)로 프록시의 X-Forwarded-Proto를 신뢰하게 해야 합니다. sameSite: 'strict'도 주의가 필요합니다. strict 쿠키는 다른 사이트에서 링크로 들어오는 최상위 이동에도 전송되지 않아서, 외부 링크로 들어온 사용자가 첫 화면에서 로그아웃된 것처럼 보이거나, OAuth state 검증처럼 제공자에서 돌아오는 콜백 시점에 기존 세션이 필요한 흐름이 실패하는 문제가 생깁니다. 저도 처음 세션 설정을 “가장 엄격하게” 잡았다가, 메일에 넣은 링크로 들어온 사용자만 매번 로그인 화면을 보는 현상 때문에 한참 헤맨 적이 있습니다. 쿠키 속성 문제는 서버 로그에 아무 오류도 남지 않으므로, 브라우저 개발자 도구의 Application 탭에서 쿠키가 실제로 전송됐는지부터 확인하는 것이 가장 빠릅니다. 일반 웹 앱이라면 lax가 CSRF 방어와 사용성 사이의 현실적인 기본값입니다.

세션 사용

// 로그인
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: '이메일 또는 비밀번호가 잘못되었습니다' });
        }
        
        // 세션에 사용자 정보 저장
        req.session.userId = user._id;
        req.session.email = user.email;
        req.session.role = user.role;
        
        res.json({
            message: '로그인 성공',
            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: '로그아웃 실패' });
        }
        
        res.clearCookie('connect.sid');
        res.json({ message: '로그아웃 성공' });
    });
});
// 세션 확인 미들웨어
function requireAuth(req, res, next) {
    if (!req.session.userId) {
        return res.status(401).json({ error: '로그인이 필요합니다' });
    }
    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 });
    }
});

로그인에 성공했을 때 req.session.userId를 바로 쓰기 전에 req.session.regenerate()로 세션 ID를 새로 발급하는 것이 좋습니다. 로그인 전의 세션 ID를 그대로 쓰면, 공격자가 미리 심어 둔 세션 ID로 피해자가 로그인하게 만드는 세션 고정(session fixation) 공격이 가능합니다. Passport의 req.login은 0.6 버전부터 이 재발급을 자동으로 해 줍니다.


OAuth 2.0 (소셜 로그인)

Passport.js 설치

npm install passport passport-google-oauth20 passport-github2

Google OAuth

// 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;
// app.js
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());
// Google 로그인 시작
app.get('/auth/google',
    passport.authenticate('google', { scope: ['profile', 'email'] })
);
// Google 콜백
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: '로그아웃 실패' });
        }
        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 });
});

serializeUser는 로그인 시 세션에 무엇을 저장할지(여기서는 _id만), deserializeUser는 이후 매 요청에서 그 값으로 사용자를 복원하는 방법을 정합니다. 사용자 객체 전체를 세션에 넣지 않는 이유는 세션이 커지고 오래된 정보가 남기 때문입니다. 대신 요청마다 DB 조회가 한 번 일어난다는 점은 알고 있어야 합니다.

처음 OAuth를 붙일 때 가장 흔히 만나는 오류는 Google 화면의 Error 400: redirect_uri_mismatch입니다. callbackURL을 상대 경로로 주면 Passport가 요청의 호스트와 프로토콜로 전체 URL을 조립하는데, 프록시 뒤에서는 https가 http로 바뀌어 콘솔에 등록한 주소와 달라지기 쉽습니다. 이럴 때는 trust proxy를 설정하거나 callbackURL을 전체 URL로 명시하면 해결됩니다. 또 profile.emails[0]는 사용자가 이메일 스코프를 거부하거나 GitHub처럼 이메일을 비공개로 둔 경우 비어 있을 수 있으므로, 이메일을 필수 키로 쓰는 스키마라면 예외 처리가 필요합니다. 기존에 이메일·비밀번호로 가입한 사용자가 같은 이메일로 소셜 로그인하면 위 코드는 googleId로만 찾기 때문에 unique 이메일 제약에 걸려 실패합니다. 계정을 연결할지, 별도 확인을 거칠지 정책을 먼저 정해야 합니다.


보안 베스트 프랙티스

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

CSP 키워드는 작은따옴표까지 값에 포함돼야 해서, JavaScript에서는 "'self'"처럼 따옴표를 이중으로 씁니다. ['self']라고 쓰면 실제 헤더에는 따옴표 없는 self가 나가고, 브라우저는 이를 self라는 호스트 이름으로 해석해 같은 출처 리소스까지 막아 버립니다. Helmet을 처음 켰을 때 인라인 스크립트나 외부 CDN이 콘솔에 Refused to load the script ... because it violates the following Content Security Policy directive 오류와 함께 막히는 것은 정상 동작이므로, 필요한 출처를 하나씩 허용 목록에 추가하면 됩니다. hsts.preload는 브라우저 프리로드 목록에 등록하겠다는 의사 표시라서, 하위 도메인까지 HTTPS가 준비되지 않았다면 켜지 않는 편이 안전합니다.

Rate Limiting

npm install express-rate-limit
const rateLimit = require('express-rate-limit');
// 일반 요청 제한
const limiter = rateLimit({
    windowMs: 15 * 60 * 1000,  // 15분
    max: 100,  // 최대 100개 요청
    message: '너무 많은 요청이 발생했습니다. 나중에 다시 시도하세요.'
});
app.use('/api/', limiter);
// 로그인 요청 제한 (더 엄격)
const loginLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 5,  // 15분에 5번만
    skipSuccessfulRequests: true  // 성공한 요청은 카운트 안 함
});
app.post('/auth/login', loginLimiter, async (req, res) => {
    // ...
});

express-rate-limit은 기본적으로 클라이언트 IP별로 요청을 셉니다. 리버스 프록시 뒤에서 trust proxy를 설정하지 않으면 모든 요청이 프록시 IP 하나로 보여, 한 사용자의 요청 폭주로 전체 사용자가 차단됩니다. 최신 버전은 이 상황을 감지하면 ERR_ERL_UNEXPECTED_X_FORWARDED_FOR 검증 오류를 로그로 알려 주므로, 이 메시지가 보이면 프록시 설정부터 확인하면 됩니다. v7부터는 max 대신 limit이라는 이름을 권장하지만 max도 계속 동작합니다. 또 기본 메모리 저장소는 프로세스마다 따로 세기 때문에 인스턴스를 여러 대 띄우면 실제 한도가 인스턴스 수만큼 늘어납니다. 이때는 Redis 저장소(rate-limit-redis)를 붙여 카운터를 공유합니다.

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']
}));
// 동적 origin
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('CORS 정책 위반'));
        }
    },
    credentials: true
}));

cors()를 인자 없이 쓰면 Access-Control-Allow-Origin: *이 나가는데, 쿠키를 함께 보내는 요청(credentials: 'include')에서는 브라우저가 와일드카드를 허용하지 않습니다. 이때 콘솔에는 “The value of the ‘Access-Control-Allow-Origin’ header in the response must not be the wildcard ’*’ when the request’s credentials mode is ‘include’” 오류가 찍힙니다. 쿠키 기반 인증을 쓴다면 위의 동적 origin처럼 허용 목록을 명시해야 합니다. CORS는 브라우저가 응답을 읽지 못하게 막을 뿐 서버에 요청이 도달하는 것은 막지 않으므로, 인증과 CSRF 방어를 대신하지 못한다는 점도 기억해야 합니다.

입력 검증

npm install express-validator
const { body, validationResult } = require('express-validator');
app.post('/auth/register',
    // 검증 규칙
    body('email')
        .isEmail().withMessage('유효한 이메일이 아닙니다')
        .normalizeEmail(),
    body('password')
        .isLength({ min: 8 }).withMessage('비밀번호는 8자 이상이어야 합니다')
        .matches(/[A-Z]/).withMessage('대문자가 포함되어야 합니다')
        .matches(/[a-z]/).withMessage('소문자가 포함되어야 합니다')
        .matches(/[0-9]/).withMessage('숫자가 포함되어야 합니다')
        .matches(/[@$!%*?&#]/).withMessage('특수문자가 포함되어야 합니다'),
    body('name')
        .trim()
        .notEmpty().withMessage('이름이 필요합니다')
        .isLength({ min: 2, max: 50 }).withMessage('이름은 2-50자여야 합니다'),
    
    // 핸들러
    async (req, res) => {
        const errors = validationResult(req);
        
        if (!errors.isEmpty()) {
            return res.status(400).json({ errors: errors.array() });
        }
        
        // 회원가입 로직
        // ...
    }
);

SQL Injection 방지

// ❌ SQL Injection 취약
async function vulnerable(email) {
    const query = `SELECT * FROM users WHERE email = '${email}'`;
    const [rows] = await pool.query(query);
    return rows;
}
// 공격: email = "' OR '1'='1"
// ✅ Prepared Statement 사용
async function safe(email) {
    const [rows] = await pool.query(
        'SELECT * FROM users WHERE email = ?',
        [email]
    );
    return rows;
}
// ✅ ORM 사용
async function safest(email) {
    return await User.findOne({ email });
}

XSS 방지

npm install xss
const xss = require('xss');
app.post('/api/posts', async (req, res) => {
    const { title, content } = req.body;
    
    // XSS 필터링
    const sanitizedTitle = xss(title);
    const sanitizedContent = xss(content);
    
    const post = await Post.create({
        title: sanitizedTitle,
        content: sanitizedContent
    });
    
    res.status(201).json(post);
});

MongoDB를 쓴다고 인젝션에서 자유로운 것은 아닙니다. User.findOne({ email: req.body.email })에 JSON 본문으로 {"email": {"$ne": null}}이 들어오면 조건이 “email이 null이 아닌 첫 사용자”로 바뀝니다. Mongoose의 sanitizeFilter 옵션을 켜거나, 위의 express-validator처럼 값이 문자열인지 먼저 검증하면 막을 수 있습니다.

XSS 필터링을 입력 시점에 하는 방식에는 트레이드오프가 있습니다. 저장 전에 걸러 두면 모든 출력 경로가 안전해지지만, 원문이 변형돼 나중에 다른 형식(마크다운, JSON API)으로 쓸 때 문제가 됩니다. 기본은 출력 시점에 템플릿 엔진이나 React의 자동 이스케이프를 이용하고, HTML을 허용해야 하는 게시판 본문처럼 꼭 필요한 곳에만 xss나 DOMPurify 같은 허용 목록 기반 정화를 적용하는 것이 일반적입니다.


실전 프로젝트: 완전한 인증 시스템

// 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 await bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
// routes/auth.js
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const User = require('../models/User');
const { sendEmail } = require('../utils/email');
const JWT_SECRET = process.env.JWT_SECRET;
// 회원가입
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: '이미 존재하는 이메일입니다' });
        }
        
        // 이메일 인증 토큰 생성
        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: '이메일 인증',
            text: `다음 링크를 클릭하여 이메일을 인증하세요: ${verificationUrl}`
        });
        
        res.status(201).json({
            message: '회원가입 성공. 이메일을 확인하세요.',
            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: '유효하지 않은 토큰입니다' });
        }
        
        user.isVerified = true;
        user.verificationToken = undefined;
        await user.save();
        
        res.json({ message: '이메일 인증 완료' });
    } 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: '이메일을 확인하세요' });
        }
        
        // 재설정 토큰 생성
        const resetToken = crypto.randomBytes(32).toString('hex');
        user.resetPasswordToken = resetToken;
        user.resetPasswordExpires = Date.now() + 3600000;  // 1시간
        await user.save();
        
        // 이메일 발송
        const resetUrl = `${req.protocol}://${req.get('host')}/auth/reset-password/${resetToken}`;
        await sendEmail({
            to: email,
            subject: '비밀번호 재설정',
            text: `다음 링크를 클릭하여 비밀번호를 재설정하세요: ${resetUrl}`
        });
        
        res.json({ message: '이메일을 확인하세요' });
    } 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: '유효하지 않거나 만료된 토큰입니다' });
        }
        
        user.password = password;
        user.resetPasswordToken = undefined;
        user.resetPasswordExpires = undefined;
        await user.save();
        
        res.json({ message: '비밀번호가 재설정되었습니다' });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});
module.exports = router;

이 구현에서 회원가입과 비밀번호 재설정은 bcrypt.hash를 직접 호출하지 않습니다. pre('save') 훅이 password가 바뀐 경우에만 해싱하기 때문입니다. 편리하지만 함정도 있습니다. User.updateOne({ _id }, { password })나 findOneAndUpdate는 save 훅을 거치지 않으므로 평문이 그대로 저장됩니다. 비밀번호를 바꾸는 코드는 반드시 문서를 불러와 save()로 저장하도록 통일해야 합니다.

재설정 토큰과 이메일 인증 토큰도 DB에 원문으로 저장돼 있습니다. DB 읽기 권한만 있으면 누구든 임의 계정의 비밀번호를 바꿀 수 있다는 뜻이므로, 메일에는 원문을 보내고 DB에는 해시를 저장한 뒤 요청이 오면 해시로 조회하는 방식이 더 안전합니다. 또 req.get('host')로 링크를 만드는 방식은 Host 헤더를 조작한 요청으로 공격자 도메인을 가리키는 재설정 링크를 피해자에게 보내게 만들 수 있습니다(Host header injection). 메일 링크의 도메인은 설정 파일의 고정값을 쓰는 편이 안전합니다.


자주 발생하는 문제

문제 1: JWT 토큰 무효화

문제: JWT는 서버에서 무효화할 수 없음 해결:

// 블랙리스트 방식
const tokenBlacklist = new Set();
app.post('/auth/logout', authenticate, (req, res) => {
    const token = req.headers.authorization.substring(7);
    tokenBlacklist.add(token);
    
    res.json({ message: '로그아웃 성공' });
});
// 미들웨어에서 확인
function authenticate(req, res, next) {
    const token = req.headers.authorization?.substring(7);
    
    if (tokenBlacklist.has(token)) {
        return res.status(401).json({ error: '무효화된 토큰입니다' });
    }
    
    // 토큰 검증...
}

위 예시는 원리를 보여 주는 최소 구현입니다. 메모리 Set은 서버를 재시작하면 비워지고, 인스턴스가 여러 대면 공유되지 않으며, 만료된 토큰이 영원히 쌓입니다. 실무에서는 토큰에 jti(고유 ID) 클레임을 넣고 Redis에 jti를 키로, 토큰의 남은 수명을 TTL로 저장해 만료와 함께 자동으로 지워지게 합니다. 블랙리스트를 조회하는 순간 JWT도 결국 매 요청 저장소를 확인하는 구조가 되므로, “강제 로그아웃이 자주 필요하다면 세션이 더 맞는 도구가 아닌가”를 다시 따져 볼 만합니다.

문제 2: 세션 스토어 메모리 누수

// ❌ 메모리 스토어 (프로덕션 부적합)
app.use(session({
    secret: 'secret',
    resave: false,
    saveUninitialized: false
    // store 설정 없음 → 메모리에 저장
}));
// ✅ 영구 스토어 사용
const MongoStore = require('connect-mongo');
app.use(session({
    secret: 'secret',
    resave: false,
    saveUninitialized: false,
    store: MongoStore.create({
        mongoUrl: 'mongodb://localhost:27017/mydb'
    })
}));

store를 지정하지 않으면 express-session은 NODE_ENV=production에서 “Warning: connect.session() MemoryStore is not designed for a production environment, as it will leak memory, and will not scale past a single process.” 경고를 출력합니다. 이 경고를 무시하고 배포하면 만료된 세션이 정리되지 않아 메모리가 계속 늘고, 배포나 재시작 때마다 모든 사용자가 로그아웃됩니다.

문제 3: 비밀번호 평문 저장

// ❌ 절대 안 됨!
const user = await User.create({
    email: '[email protected]',
    password: 'mypassword123'  // 평문 저장
});
// ✅ 해싱하여 저장
const hashedPassword = await bcrypt.hash('mypassword123', 10);
const user = await User.create({
    email: '[email protected]',
    password: hashedPassword
});

JWT와 세션, 무엇으로 시작할지

둘 중 하나를 고르는 기준은 대부분 “토큰을 즉시 무효화해야 하는 순간이 있는가”입니다. 세션은 서버 저장소에서 지우는 순간 끝나지만, JWT는 서명이 맞으면 만료 시각까지 유효합니다. 비밀번호 변경, 계정 정지, 기기 분실 대응처럼 “지금 당장 끊어야 하는” 요구가 있다면 JWT만으로는 부족하고 결국 블랙리스트나 Refresh Token 저장소라는 서버 상태가 다시 생깁니다. 브라우저가 주 클라이언트인 일반 웹 앱이라면 httpOnly 쿠키에 담은 세션이 더 단순하고, JWT는 여러 서비스가 같은 토큰을 검증해야 하거나 모바일·서드파티 클라이언트가 붙는 API에서 장점이 살아납니다.

특징JWT세션
상태 저장 위치토큰 자체(클라이언트)서버 세션 스토어
즉시 무효화어려움(블랙리스트 필요)쉬움(스토어에서 삭제)
여러 서버 확장공유 비밀키·공개키만 있으면 됨Redis 같은 공유 스토어 필요
요청마다 오가는 크기클레임 전체세션 ID

비밀번호 규칙: 조합보다 길이와 유출 목록

“대문자·소문자·숫자·특수문자를 모두 포함”처럼 조합을 강제하는 규칙은 흔하지만, 사용자는 Password1!처럼 예측 가능한 패턴으로 맞추는 경향이 있어 실제 강도는 기대만큼 오르지 않습니다. NIST SP 800-63B도 조합 규칙을 강제하지 말고 최소 길이를 두고, 이미 유출된 비밀번호 목록과 대조하라고 권고합니다. 그래서 검증은 길이 하한과 상한, 유출 목록 대조 정도로 두는 편이 낫습니다.

function validatePassword(password) {
    const errors = [];
    if (password.length < 12) {
        errors.push('12자 이상이어야 합니다');
    }
    // bcrypt는 입력의 앞 72바이트만 사용합니다
    if (Buffer.byteLength(password, 'utf8') > 72) {
        errors.push('비밀번호가 너무 깁니다');
    }
    return errors;
}

상한 검사가 들어간 이유는 bcrypt 쪽 함정 때문입니다. bcrypt는 입력의 앞 72바이트만 해시에 반영하고 나머지는 조용히 버립니다. 영문이면 72자라 거의 문제가 안 되지만, UTF-8에서 한글은 한 글자가 3바이트라 24자를 넘는 한글 비밀번호는 뒷부분이 달라도 같은 비밀번호로 통과합니다. 긴 문장형 비밀번호를 허용하려면 이렇게 거부하거나, Argon2처럼 이런 제한이 없는 알고리즘을 쓰는 방법이 있습니다. 유출 목록 대조는 Have I Been Pwned의 k-익명성 API처럼 해시 앞 5자리만 보내는 방식을 쓰면 비밀번호 자체를 외부로 보내지 않고 확인할 수 있습니다.

비밀 키는 서버가 뜰 때 검증하기

JWT_SECRET이 비어 있으면 jsonwebtoken의 sign은 에러를 내지만, 그 시점은 첫 로그인 요청이 들어온 뒤입니다. 배포 직후 헬스체크는 통과하고 로그인만 500을 내는 상태가 되기 쉬우므로, 설정을 읽는 모듈에서 시작 시점에 검사하고 바로 죽게 만드는 편이 안전합니다.

# .env (저장소에 커밋하지 않습니다)
JWT_SECRET=...
REFRESH_SECRET=...
SESSION_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과 REFRESH_SECRET이 필요합니다');
}
module.exports = config;

Access Token과 Refresh Token에 서로 다른 비밀키를 쓰는 것도 같은 맥락입니다. 같은 키를 쓰면 Refresh Token을 Access Token 자리에 넣어도 서명 검증을 통과하므로, 토큰 종류를 클레임으로 따로 확인하지 않는 한 수명이 긴 토큰이 API 호출에 그대로 쓰일 수 있습니다.

다음 단계

참고 자료: JWT.io, Passport.js, OWASP Top 10, OWASP Password Storage Cheat Sheet


자주 묻는 질문 (FAQ)

Q. JWT를 쓰면 로그아웃이나 강제 만료는 어떻게 처리하나요?

A. JWT는 서명만 맞으면 만료 시각까지 유효하기 때문에 서버가 발급한 토큰을 직접 취소할 수 없습니다. 그래서 로그아웃한 토큰을 블랙리스트에 넣고 인증 미들웨어에서 확인하거나, Access Token 만료를 짧게 두고 Refresh Token을 서버 저장소에서 폐기하는 방식을 씁니다. 블랙리스트를 메모리 Set에 두면 서버 재시작이나 다중 인스턴스에서 공유되지 않으므로 운영에서는 Redis 같은 공유 저장소에 두는 것이 좋습니다.


같이 보면 좋은 글