인증과 보안 — JWT, Passport.js, 세션 관리
로그인한 사용자를 "기억"하는 방법은 여러 가지인데, 세션과 JWT 중 어떤 걸 선택해야 할까요?
HTTP는 본질적으로 상태가 없습니다(stateless). 매 요청이 독립적이기 때문에, 서버가 "이 사용자는 로그인했다"는 사실을 기억하려면 별도의 메커니즘이 필요합니다. 이 선택이 아키텍처 전체에 영향을 미칩니다.
개념 정의
인증(Authentication) 은 "이 사용자가 누구인지" 확인하는 과정입니다. 인가(Authorization) 는 "이 사용자가 이 자원에 접근할 수 있는지" 판단하는 과정입니다. 이 글에서는 인증에 집중합니다.
세션 vs JWT — 두 가지 전략
| 기준 | 세션 기반 | JWT 기반 |
|---|---|---|
| 상태 저장 | 서버에 세션 저장 (메모리/Redis) | 클라이언트에 토큰 저장 |
| 확장성 | 서버 간 세션 공유 필요 | 상태 없음, 수평 확장 쉬움 |
| 로그아웃 | 서버에서 세션 삭제하면 즉시 만료 | 토큰 무효화가 어려움 |
| 보안 | 세션 ID만 전송 (데이터는 서버) | 페이로드가 클라이언트에 노출 |
| 용량 | 쿠키에 세션 ID만 (~32bytes) | 토큰 자체가 큼 ( |
| 적합한 경우 | 단일 서버, 즉시 만료 필요 | 마이크로서비스, 모바일 앱 |
JWT 동작 원리
JWT(JSON Web Token)는 세 부분으로 구성됩니다.
헤더.페이로드.서명
eyJhbGci... . eyJ1c2Vy... . SflKxwRJ...
- **헤더 **: 알고리즘과 토큰 타입 (
{"alg": "HS256", "typ": "JWT"}) - ** 페이로드 **: 사용자 정보 (claims). Base64 인코딩이지 ** 암호화가 아닙니다 **.
- ** 서명 **: 헤더 + 페이로드를 서버의 비밀키로 서명한 값
인증 흐름은 다음과 같습니다.
| 순서 | 클라이언트 | 방향 | 서버 | 비고 |
|---|---|---|---|---|
| 1 | 이메일 + 비밀번호 | --> | 검증 | 로그인 요청 |
| 2 | <-- | JWT 발급 | Access + Refresh 토큰 | |
| 3 | Authorization: Bearer {token} | --> | 서명 검증 | API 요청마다 |
| 4 | <-- | 응답 데이터 | 검증 성공 시 |
Express에서 JWT를 구현하면 다음과 같습니다.
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
// 로그인 — 토큰 발급
app.post('/login', async (req, res) => {
const user = await User.findByEmail(req.body.email);
if (!user || !await bcrypt.compare(req.body.password, user.password)) {
return res.status(401).json({ error: '인증 실패' });
}
const token = jwt.sign({ id: user.id }, SECRET, { expiresIn: '1h' });
res.json({ token });
});
// 인증 미들웨어 — 토큰 검증
function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: '토큰 없음' });
try {
req.user = jwt.verify(token, SECRET); // ← 서명 검증 + 만료 체크
next();
} catch {
return res.status(401).json({ error: '유효하지 않은 토큰' });
}
}
jwt.verify()는 서명 검증과 만료 시간 체크를 동시에 수행합니다. 위변조된 토큰이나 만료된 토큰은 여기서 걸립니다.
Passport.js — 인증 전략 추상화
Passport.js는 인증 로직을 Strategy 패턴 으로 추상화합니다. 로컬 로그인, JWT, OAuth 등을 동일한 인터페이스로 처리합니다.
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const JwtStrategy = require('passport-jwt').Strategy;
// Local 전략 — 이메일/비밀번호 로그인
passport.use(new LocalStrategy(
{ usernameField: 'email' },
async (email, password, done) => {
const user = await User.findByEmail(email);
if (!user) return done(null, false);
if (!await bcrypt.compare(password, user.password)) return done(null, false);
return done(null, user);
}
));
// JWT 전략 — 토큰 검증
passport.use(new JwtStrategy(
{ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: SECRET },
async (payload, done) => {
const user = await User.findById(payload.id);
return done(null, user || false);
}
));
// 라우트에서 사용
app.post('/login', passport.authenticate('local', { session: false }), (req, res) => {
const token = jwt.sign({ id: req.user.id }, SECRET, { expiresIn: '1h' });
res.json({ token });
});
app.get('/profile', passport.authenticate('jwt', { session: false }), (req, res) => {
res.json(req.user);
});
{ session: false }를 지정하면 Passport가 세션을 사용하지 않습니다. JWT 기반 인증에서는 항상 이 옵션을 켜야 합니다.
보안 미들웨어
인증만으로는 보안이 완성되지 않습니다. Express 애플리케이션에 필수적인 보안 미들웨어를 살펴보겠습니다.
helmet — HTTP 헤더 보안
const helmet = require('helmet');
app.use(helmet()); // ← 한 줄로 11개 보안 헤더 설정
helmet은 X-Content-Type-Options, Strict-Transport-Security, X-Frame-Options 등의 보안 헤더를 자동으로 설정합니다. 기본 설정만으로도 XSS, 클릭재킹 등의 공격 표면을 줄여줍니다.
cors — Cross-Origin Resource Sharing
const cors = require('cors');
app.use(cors({
origin: 'https://myapp.com', // ← 허용할 출처
methods: ['GET', 'POST', 'PUT'],
credentials: true, // ← 쿠키 전송 허용
}));
cors()를 인자 없이 호출하면 모든 출처를 허용합니다. 프로덕션에서는 반드시 origin을 지정해야 합니다.
express-rate-limit — 요청 제한
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 100, // IP당 최대 100회
message: { error: 'Too many requests' },
});
app.use('/api', limiter);
브루트포스 공격이나 API 남용을 방지합니다. 로그인 엔드포인트에는 더 엄격한 제한을 걸어야 합니다.
주의할 점
JWT 페이로드에 민감한 정보 넣지 않기
JWT의 페이로드는 Base64 인코딩 이지 암호화 가 아닙니다. 누구나 디코딩할 수 있습니다.
// 위험 — 비밀번호 해시가 토큰에 노출
jwt.sign({ id: user.id, passwordHash: user.password }, SECRET);
// 안전 — 최소한의 식별 정보만
jwt.sign({ id: user.id, role: user.role }, SECRET);
JWT 무효화(로그아웃)가 어렵다
JWT는 서버에 상태가 없기 때문에, 발급된 토큰을 "무효화"할 방법이 기본적으로 없습니다. 만료 시간 전까지 유효합니다.
해결 방법은 세 가지입니다.
- Access Token 수명을 짧게 (15분) + Refresh Token으로 갱신
- ** 블랙리스트 **: Redis에 로그아웃된 토큰 ID를 저장하고 검증 시 체크
- ** 토큰 버전 **: DB에 사용자별 토큰 버전을 두고, 버전 불일치 시 거부
비밀키를 코드에 하드코딩하지 않기
// 절대 금지
const SECRET = 'my-super-secret-key';
// 환경 변수로 분리
const SECRET = process.env.JWT_SECRET;
정리
| 항목 | 설명 |
|---|---|
| 세션 vs JWT | 서버 상태 저장 vs 클라이언트 상태, 트레이드오프 |
| JWT 구조 | 헤더.페이로드.서명, 페이로드는 암호화가 아닌 인코딩 |
| Passport.js | Strategy 패턴으로 인증 전략 추상화 |
| helmet | HTTP 보안 헤더 자동 설정 |
| cors | 프로덕션에서 반드시 origin 지정 |
| rate-limit | 브루트포스/남용 방지, 엔드포인트별 차등 적용 |
| JWT 무효화 | 짧은 만료 + Refresh Token 또는 블랙리스트 |