JWT & OAuth 2.0 인증 실전 가이드 | 토큰 기반 인증
이 글의 핵심
JWT로 상태 없이 검증하는 흐름과, OAuth로 외부 로그인을 붙인 뒤 우리 서비스 토큰을 발급하는 흐름을 예제로 정리한다. 이론 체크리스트보다는 배포에서 자주 묻는 지점 위주다.
목차
사전 지식 (초보자를 위한 기초)
1. 인증(Authentication) vs 인가(Authorization)
인증 (Authentication): “당신은 누구인가?”
로그인 흐름 예:
- 사용자가 아이디·비밀번호를 보낸다
- 서버가 검증해 본인이면 이후 요청에 쓸 수단(세션·토큰 등)을 준다
인가 (Authorization): “무엇을 할 수 있는가?”
권한 확인 예:
- 삭제 요청이 오면 역할(관리자·일반)을 본다
- 정책에 없으면 거절한다
인증은 “누구인지”, 인가는 “무엇을 할 수 있는지”다. 둘을 섞어 말하면 설계가 흐려지기 쉽다.
2. HTTP는 Stateless
Stateless는 서버가 이전 요청을 기억하지 않는다는 뜻이다.
문제 상황:
요청 1: POST /login (로그인 성공!)
요청 2: GET /profile (누구인지 모름)
요청 3: GET /posts (누구인지 모름)
HTTP는 각 요청을 독립적으로 처리하므로
서버는 "이 사용자가 로그인했다"는 것을 기억 못함!
해결 방법:
- 세션: 서버에 로그인 정보 저장
- 토큰: 클라이언트가 신분증(토큰) 들고 다님
3. 쿠키(Cookie)란?
쿠키는 브라우저가 저장했다가 같은 사이트 요청에 실어 보내는 작은 데이터다.
// 서버가 쿠키 설정
res.cookie('sessionId', 'abc123', {
httpOnly: true, // JavaScript로 접근 불가 (보안)
secure: true, // HTTPS에서만 전송
maxAge: 3600000 // 1시간
});
// 이후 모든 요청에 자동으로 쿠키 포함
// Cookie: sessionId=abc123
1. 인증이란?
인증의 필요성
인증이 없다면:
- 누구나 다른 사람의 정보 조회 가능
- 누구나 게시글 삭제 가능
- 보안 사고 발생!
인증이 있다면:
- 로그인한 사용자만 접근
- 본인 데이터만 조회/수정
- 안전한 서비스 운영
인증 방식의 진화
1세대: 기본 인증 (Basic Auth)
- ID/PW를 매 요청마다 전송
- 보안 취약 (Base64 인코딩만)
2세대: 세션 기반 인증
- 서버에 로그인 정보 저장
- 세션 ID를 쿠키로 전송
- 서버 메모리 사용
3세대: 토큰 기반 인증 (JWT)
- 서버가 상태 저장 안함
- 토큰에 모든 정보 포함
- 확장성 좋음
4세대: OAuth 2.0
- 소셜 로그인 (구글, 카카오)
- 제3자 인증 위임
2. 세션 vs 토큰 인증
세션 기반 인증
┌─────────┐ ┌─────────┐
│ Client │ │ Server │
└────┬────┘ └────┬────┘
│ │
│ 1. POST /login │
│ (ID: alice, PW: 1234) │
├─────────────────────────────>│
│ │ 2. 비밀번호 확인
│ │ 3. 세션 생성 (메모리 저장)
│ │ sessions['abc123'] = { userId: 1 }
│ 4. Set-Cookie: sessionId=abc123
│<─────────────────────────────┤
│ │
│ 5. GET /profile │
│ Cookie: sessionId=abc123 │
├─────────────────────────────>│
│ │ 6. 세션 확인
│ │ sessions['abc123'] → userId: 1
│ 7. { name: "Alice", ... } │
│<─────────────────────────────┤
장점:
- ✅ 서버에서 세션 제어 가능 (강제 로그아웃)
- ✅ 세션 데이터 서버에서 관리
단점:
- ❌ 서버 메모리 사용 (사용자 많으면 부담)
- ❌ 수평 확장 어려움 (세션 공유 필요)
- ❌ CORS 문제 (쿠키 전송 제한)
토큰 기반 인증 (JWT)
┌─────────┐ ┌─────────┐
│ Client │ │ Server │
└────┬────┘ └────┬────┘
│ │
│ 1. POST /login │
│ (ID: alice, PW: 1234) │
├─────────────────────────────>│
│ │ 2. 비밀번호 확인
│ │ 3. JWT 생성 (서명)
│ │ token = sign({ userId: 1 })
│ 4. { token: "eyJhbG..." } │
│<─────────────────────────────┤
│ 5. localStorage에 저장 │
│ │
│ 6. GET /profile │
│ Authorization: Bearer eyJhbG...
├─────────────────────────────>│
│ │ 7. JWT 검증 (서명 확인)
│ │ verify(token) → userId: 1
│ 8. { name: "Alice", ... } │
│<─────────────────────────────┤
장점:
- ✅ 서버가 상태 저장 안함 (Stateless)
- ✅ 수평 확장 쉬움
- ✅ 모바일 앱 친화적
단점:
- ❌ 토큰 크기 큼 (매 요청마다 전송)
- ❌ 강제 로그아웃 어려움
- ❌ 토큰 탈취 시 위험
3. JWT 기초
JWT 구조
JWT (JSON Web Token)는 3개 부분으로 구성됩니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTYxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└────────────── Header ──────────────┘ └─────────── Payload ───────────┘ └──────────── Signature ────────────┘
1) Header (헤더)
{
"alg": "HS256", // 알고리즘
"typ": "JWT" // 타입
}
2) Payload (페이로드)
{
"userId": 1,
"email": "[email protected]",
"role": "admin",
"iat": 1616239022, // 발급 시간
"exp": 1616242622 // 만료 시간
}
3) Signature (서명)
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret // 비밀 키
)
JWT 동작 원리
1. 로그인 성공 시:
서버가 JWT 생성 → 클라이언트에게 전달
2. 이후 요청 시:
클라이언트가 JWT를 헤더에 포함해서 전송
Authorization: Bearer <token>
3. 서버 검증:
- 서명 확인 (위조 여부)
- 만료 시간 확인
- Payload에서 사용자 정보 추출
4. JWT 구현 (Node.js)
패키지 설치
npm install express jsonwebtoken bcrypt
기본 구현
server.js
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const SECRET_KEY = 'your-secret-key-change-this';
const users = []; // 실제로는 DB 사용
// 회원가입
app.post('/register', async (req, res) => {
const { email, password } = req.body;
// 비밀번호 해싱
const hashedPassword = await bcrypt.hash(password, 10);
const user = {
id: users.length + 1,
email,
password: hashedPassword
};
users.push(user);
res.status(201).json({ message: '회원가입 성공' });
});
// 로그인
app.post('/login', async (req, res) => {
const { email, password } = req.body;
// 사용자 찾기
const user = users.find(u => u.email === 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(
{ userId: user.id, email: user.email }, // Payload
SECRET_KEY, // Secret
{ expiresIn: '1h' } // 옵션
);
res.json({ token });
});
// 인증 미들웨어
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: '토큰이 없습니다' });
}
jwt.verify(token, SECRET_KEY, (err, user) => {
if (err) {
return res.status(403).json({ error: '유효하지 않은 토큰입니다' });
}
req.user = user;
next();
});
}
// 보호된 라우트
app.get('/profile', authenticateToken, (req, res) => {
// req.user에 JWT payload 정보 있음
res.json({
message: '프로필 조회 성공',
user: req.user
});
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
사용 예제
# 1. 회원가입
curl -X POST http://localhost:3000/register \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"1234"}'
# 2. 로그인
curl -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"1234"}'
# 응답: {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
# 3. 프로필 조회 (토큰 필요)
curl http://localhost:3000/profile \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
5. Refresh Token 전략
문제: Access Token 만료
Access Token 유효 기간: 15분
15분 후:
- 사용자가 페이지 사용 중
- 토큰 만료 → 로그인 화면으로 이동 😱
- 사용자 불편!
해결: Refresh Token
Access Token (짧은 수명):
- 유효 기간: 15분
- API 요청에 사용
- 탈취 시 피해 최소화
Refresh Token (긴 수명):
- 유효 기간: 7일
- Access Token 재발급에만 사용
- DB에 저장 (강제 로그아웃 가능)
Refresh Token 구현
const jwt = require('jsonwebtoken');
const ACCESS_TOKEN_SECRET = 'access-secret';
const REFRESH_TOKEN_SECRET = 'refresh-secret';
let refreshTokens = []; // 실제로는 DB 사용
// 로그인
app.post('/login', async (req, res) => {
const { email, password } = req.body;
// 사용자 인증 (생략)
const user = { id: 1, email };
// Access Token 생성 (15분)
const accessToken = jwt.sign(
{ userId: user.id, email: user.email },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
// Refresh Token 생성 (7일)
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// Refresh Token 저장 (DB)
refreshTokens.push(refreshToken);
res.json({ accessToken, refreshToken });
});
// Access Token 재발급
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh Token이 없습니다' });
}
// Refresh Token이 DB에 있는지 확인
if (!refreshTokens.includes(refreshToken)) {
return res.status(403).json({ error: '유효하지 않은 Refresh Token' });
}
// Refresh Token 검증
jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: '만료된 Refresh Token' });
}
// 새로운 Access Token 발급
const accessToken = jwt.sign(
{ userId: user.userId },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken });
});
});
// 로그아웃
app.post('/logout', (req, res) => {
const { refreshToken } = req.body;
// Refresh Token 삭제 (DB에서 제거)
refreshTokens = refreshTokens.filter(token => token !== refreshToken);
res.json({ message: '로그아웃 성공' });
});
클라이언트 구현 (React)
// API 요청 함수
async function apiRequest(url, options = {}) {
let accessToken = localStorage.getItem('accessToken');
// Access Token 포함
options.headers = {
...options.headers,
'Authorization': `Bearer ${accessToken}`
};
let response = await fetch(url, options);
// Access Token 만료 시
if (response.status === 401) {
// Refresh Token으로 재발급
const refreshToken = localStorage.getItem('refreshToken');
const refreshResponse = await fetch('/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
if (refreshResponse.ok) {
const { accessToken: newAccessToken } = await refreshResponse.json();
localStorage.setItem('accessToken', newAccessToken);
// 원래 요청 재시도
options.headers['Authorization'] = `Bearer ${newAccessToken}`;
response = await fetch(url, options);
} else {
// Refresh Token도 만료 → 로그인 페이지로
window.location.href = '/login';
}
}
return response;
}
// 사용 예시
const data = await apiRequest('/api/profile').then(r => r.json());
6. OAuth 2.0 기초
OAuth 2.0이란?
OAuth 2.0은 제3자 앱이 사용자 동의 하에 리소스에 접근하도록 권한을 위임하는 프로토콜이다.
비유:
호텔 발렛 파킹:
- 당신: 리소스 소유자 (차 주인)
- 발렛 직원: 제3자 애플리케이션
- 차: 보호된 리소스
- 발렛 키: Access Token (트렁크 못 열게 제한)
OAuth도 마찬가지:
- 구글 계정 (리소스)
- 우리 앱 (제3자)
- 제한된 권한 (이메일만 조회)
OAuth 2.0 플로우
┌─────────┐ ┌─────────┐ ┌─────────┐
│ User │ │ Our │ │ Google │
│ │ │ App │ │ (OAuth)│
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ 1. "구글로 로그인" 클릭 │ │
├───────────────────────────>│ │
│ │ 2. 구글 로그인 페이지로 리다이렉트
│ ├───────────────────────────>│
│ 3. 구글 로그인 화면 │ │
│<────────────────────────────────────────────────────────┤
│ │ │
│ 4. ID/PW 입력, 권한 승인 │ │
├────────────────────────────────────────────────────────>│
│ │ │ 5. 인증 코드 생성
│ 6. 인증 코드와 함께 리다이렉트 │
│<────────────────────────────────────────────────────────┤
│ │ │
│ 7. 인증 코드 전달 │ │
├───────────────────────────>│ │
│ │ 8. 인증 코드로 Access Token 요청
│ ├───────────────────────────>│
│ │ 9. Access Token 발급 │
│ │<───────────────────────────┤
│ │ 10. 사용자 정보 요청 │
│ ├───────────────────────────>│
│ │ 11. 사용자 정보 (이메일 등) │
│ │<───────────────────────────┤
│ 12. 로그인 완료 │ │
│<───────────────────────────┤ │
OAuth 2.0 주요 용어
1) Resource Owner (리소스 소유자)
- 사용자 (당신)
2) Client (클라이언트)
- 우리 애플리케이션
3) Authorization Server (인증 서버)
- 구글, 카카오 등의 OAuth 서버
4) Resource Server (리소스 서버)
- 사용자 정보를 제공하는 서버
5) Authorization Code (인증 코드)
- 임시 코드, Access Token으로 교환
6) Access Token (액세스 토큰)
- 리소스 접근 권한
7. OAuth 2.0 구현
Google OAuth 설정
1) Google Cloud Console 설정
1. https://console.cloud.google.com 접속
2. 프로젝트 생성
3. "API 및 서비스" → "사용자 인증 정보"
4. "OAuth 2.0 클라이언트 ID" 생성
5. 승인된 리디렉션 URI 추가:
http://localhost:3000/auth/google/callback
6. Client ID와 Client Secret 복사
Passport.js를 사용한 구현
npm install passport passport-google-oauth20 express-session
server.js
const express = require('express');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const session = require('express-session');
const app = express();
// 세션 설정
app.use(session({
secret: 'session-secret',
resave: false,
saveUninitialized: false
}));
app.use(passport.initialize());
app.use(passport.session());
// Google OAuth 전략 설정
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/auth/google/callback'
},
(accessToken, refreshToken, profile, done) => {
// 사용자 정보 저장 (DB)
const user = {
id: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
picture: profile.photos[0].value
};
return done(null, user);
}
));
// 세션 직렬화
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser((id, done) => {
// DB에서 사용자 조회
const user = { id, email: '[email protected]' };
done(null, user);
});
// 구글 로그인 시작
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('/logout', (req, res) => {
req.logout((err) => {
if (err) return next(err);
res.redirect('/');
});
});
// 보호된 라우트
app.get('/dashboard', (req, res) => {
if (!req.isAuthenticated()) {
return res.redirect('/login');
}
res.json({ user: req.user });
});
app.listen(3000);
프론트엔드 구현
<!DOCTYPE html>
<html>
<head>
<title>OAuth Login</title>
</head>
<body>
<h1>로그인</h1>
<!-- 구글 로그인 버튼 -->
<a href="/auth/google">
<button>구글로 로그인</button>
</a>
<!-- 카카오 로그인 버튼 -->
<a href="/auth/kakao">
<button>카카오로 로그인</button>
</a>
</body>
</html>
8. 카카오 OAuth 구현
카카오 개발자 설정
1. https://developers.kakao.com 접속
2. 애플리케이션 추가
3. "플랫폼" → "Web" 추가
4. Redirect URI 설정:
http://localhost:3000/auth/kakao/callback
5. "동의 항목" 설정 (이메일, 프로필)
6. REST API 키 복사
Passport-Kakao 구현
npm install passport-kakao
const KakaoStrategy = require('passport-kakao').Strategy;
passport.use(new KakaoStrategy({
clientID: process.env.KAKAO_CLIENT_ID,
callbackURL: 'http://localhost:3000/auth/kakao/callback'
},
(accessToken, refreshToken, profile, done) => {
const user = {
id: profile.id,
email: profile._json.kakao_account.email,
name: profile.displayName,
picture: profile._json.properties.profile_image
};
return done(null, user);
}
));
// 카카오 로그인 라우트
app.get('/auth/kakao',
passport.authenticate('kakao')
);
app.get('/auth/kakao/callback',
passport.authenticate('kakao', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
9. 보안 모범 사례
JWT 보안
1) Secret Key 관리
// ❌ 나쁜 예: 코드에 하드코딩
const SECRET = 'my-secret-key';
// ✅ 좋은 예: 환경 변수 사용
const SECRET = process.env.JWT_SECRET;
// .env 파일
JWT_SECRET=randomly-generated-long-secret-key-256-bits
2) HTTPS 사용 필수
// ❌ HTTP에서 토큰 전송 (탈취 위험)
http://example.com/api?token=eyJhbG...
// ✅ HTTPS에서만 토큰 전송
https://example.com/api
Authorization: Bearer eyJhbG...
3) 짧은 만료 시간
// ❌ 너무 긴 만료 시간
jwt.sign(payload, secret, { expiresIn: '30d' });
// ✅ 짧은 만료 시간 + Refresh Token
jwt.sign(payload, secret, { expiresIn: '15m' });
4) 민감한 정보 저장 금지
// ❌ 비밀번호 저장
const token = jwt.sign({ userId: 1, password: '1234' }, secret);
// ✅ 최소한의 정보만
const token = jwt.sign({ userId: 1, role: 'user' }, secret);
XSS 방어
XSS (Cross-Site Scripting): 악성 스크립트 삽입 공격
// ❌ localStorage에 토큰 저장 (XSS 취약)
localStorage.setItem('token', token);
// 악성 스크립트: localStorage.getItem('token')
// ✅ HttpOnly 쿠키에 저장
res.cookie('token', token, {
httpOnly: true, // JavaScript로 접근 불가
secure: true, // HTTPS에서만 전송
sameSite: 'strict' // CSRF 방어
});
CSRF 방어
CSRF (Cross-Site Request Forgery): 사용자 모르게 요청 전송
// CSRF 토큰 생성
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.use(csrfProtection);
app.get('/form', (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
app.post('/process', csrfProtection, (req, res) => {
res.send('처리 완료');
});
<!-- 폼에 CSRF 토큰 포함 -->
<form method="POST" action="/process">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit">제출</button>
</form>
10. 실전 패턴
역할 기반 접근 제어 (RBAC)
// 역할 확인 미들웨어
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ error: '권한이 없습니다' });
}
next();
};
}
// 관리자만 접근 가능
app.delete('/users/:id',
authenticateToken,
requireRole('admin'),
(req, res) => {
// 사용자 삭제
res.json({ message: '삭제 완료' });
}
);
// 다중 역할 지원
function requireAnyRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: '권한이 없습니다' });
}
next();
};
}
app.get('/admin/dashboard',
authenticateToken,
requireAnyRole('admin', 'moderator'),
(req, res) => {
res.json({ message: '관리자 대시보드' });
}
);
토큰 블랙리스트
const redis = require('redis');
const client = redis.createClient();
// 로그아웃 시 토큰 블랙리스트에 추가
app.post('/logout', authenticateToken, async (req, res) => {
const token = req.headers['authorization'].split(' ')[1];
// JWT에서 만료 시간 추출
const decoded = jwt.decode(token);
const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);
// Redis에 블랙리스트 저장
await client.setex(`blacklist:${token}`, expiresIn, 'true');
res.json({ message: '로그아웃 성공' });
});
// 인증 미들웨어에서 블랙리스트 확인
async function authenticateToken(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: '토큰이 없습니다' });
}
// 블랙리스트 확인
const isBlacklisted = await client.get(`blacklist:${token}`);
if (isBlacklisted) {
return res.status(401).json({ error: '로그아웃된 토큰입니다' });
}
jwt.verify(token, SECRET_KEY, (err, user) => {
if (err) return res.status(403).json({ error: '유효하지 않은 토큰' });
req.user = user;
next();
});
}
11. 비교표
인증 방식 비교
| 항목 | 세션 | JWT | OAuth 2.0 |
|---|---|---|---|
| 저장 위치 | 서버 메모리/DB | 클라이언트 | 제3자 서버 |
| 확장성 | 낮음 | 높음 | 높음 |
| 강제 로그아웃 | 쉬움 | 어려움 | 쉬움 |
| CORS | 제한적 | 자유로움 | 자유로움 |
| 모바일 앱 | 어려움 | 쉬움 | 쉬움 |
| 복잡도 | 낮음 | 중간 | 높음 |
| 보안 | 높음 | 중간 | 높음 |
선택 가이드
세션 사용:
- 전통적인 웹 애플리케이션
- 서버 1대 또는 세션 공유 가능
- 강력한 보안 필요
JWT 사용:
- RESTful API
- 마이크로서비스
- 모바일 앱
- 수평 확장 필요
OAuth 2.0 사용:
- 소셜 로그인 필요
- 제3자 API 접근
- 사용자 편의성 중요
12. 트러블슈팅
자주 발생하는 문제
1) “jwt malformed” 에러
// 원인: 잘못된 토큰 형식
// 해결: Bearer 접두사 제거
const token = authHeader.split(' ')[1]; // "Bearer TOKEN"
2) “jwt expired” 에러
// 원인: 토큰 만료
// 해결: Refresh Token으로 재발급
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'expired', message: '토큰이 만료되었습니다' });
}
3) CORS 에러
// 해결: CORS 설정
const cors = require('cors');
app.use(cors({
origin: 'http://localhost:3001',
credentials: true // 쿠키 전송 허용
}));
4) OAuth 콜백 에러
원인: Redirect URI 불일치
해결:
1. OAuth 제공자 설정 확인
2. 정확한 URI 입력 (http vs https, 포트 번호)
3. 로컬 테스트: http://localhost:3000/auth/google/callback
13. 프로덕션 체크리스트
보안 체크리스트
✅ HTTPS 사용
✅ Secret Key 환경 변수로 관리
✅ 짧은 Access Token 만료 시간 (15분)
✅ Refresh Token DB 저장
✅ 비밀번호 해싱 (bcrypt, scrypt)
✅ Rate Limiting (무차별 대입 공격 방어)
✅ CORS 설정
✅ HttpOnly, Secure 쿠키
✅ XSS 방어 (입력 검증, sanitization)
✅ CSRF 토큰
Rate Limiting 구현
const rateLimit = require('express-rate-limit');
// 로그인 엔드포인트 제한
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 5, // 최대 5회 시도
message: '너무 많은 로그인 시도입니다. 15분 후 다시 시도하세요.'
});
app.post('/login', loginLimiter, async (req, res) => {
// 로그인 로직
});
14. 실전 예제: 완전한 인증 시스템
프로젝트 구조
auth-system/
├── src/
│ ├── config/
│ │ └── passport.js
│ ├── middleware/
│ │ ├── auth.js
│ │ └── rateLimiter.js
│ ├── models/
│ │ └── User.js
│ ├── routes/
│ │ ├── auth.js
│ │ └── users.js
│ └── server.js
├── .env
└── package.json
완전한 구현
src/middleware/auth.js
const jwt = require('jsonwebtoken');
exports.authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: '인증이 필요합니다' });
}
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'expired' });
}
return res.status(403).json({ error: '유효하지 않은 토큰' });
}
req.user = user;
next();
});
};
exports.requireRole = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: '권한이 없습니다' });
}
next();
};
};
src/routes/auth.js
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const router = express.Router();
// 회원가입
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 hashedPassword = await bcrypt.hash(password, 10);
// 사용자 생성
const user = await User.create({
email,
password: hashedPassword,
name
});
res.status(201).json({
message: '회원가입 성공',
userId: user.id
});
} catch (error) {
res.status(500).json({ error: '서버 오류' });
}
});
// 로그인
router.post('/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: '이메일 또는 비밀번호가 틀렸습니다' });
}
// Access Token 생성
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
// Refresh Token 생성
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// Refresh Token DB에 저장
await user.update({ refreshToken });
res.json({ accessToken, refreshToken });
} catch (error) {
res.status(500).json({ error: '서버 오류' });
}
});
// 토큰 재발급
router.post('/refresh', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh Token이 없습니다' });
}
// JWT 검증
const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
// DB에서 Refresh Token 확인
const user = await User.findOne({
id: decoded.userId,
refreshToken
});
if (!user) {
return res.status(403).json({ error: '유효하지 않은 Refresh Token' });
}
// 새로운 Access Token 발급
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken });
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Refresh Token이 만료되었습니다' });
}
res.status(403).json({ error: '유효하지 않은 Refresh Token' });
}
});
// 로그아웃
router.post('/logout', async (req, res) => {
try {
const { refreshToken } = req.body;
// DB에서 Refresh Token 삭제
await User.updateOne(
{ refreshToken },
{ $unset: { refreshToken: 1 } }
);
res.json({ message: '로그아웃 성공' });
} catch (error) {
res.status(500).json({ error: '서버 오류' });
}
});
module.exports = router;
15. 테스트
JWT 테스트
const request = require('supertest');
const app = require('./server');
describe('Authentication', () => {
let accessToken;
it('회원가입 성공', async () => {
const res = await request(app)
.post('/register')
.send({
email: '[email protected]',
password: '1234',
name: 'Test User'
});
expect(res.status).toBe(201);
});
it('로그인 성공', async () => {
const res = await request(app)
.post('/login')
.send({
email: '[email protected]',
password: '1234'
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('accessToken');
accessToken = res.body.accessToken;
});
it('보호된 라우트 접근', async () => {
const res = await request(app)
.get('/profile')
.set('Authorization', `Bearer ${accessToken}`);
expect(res.status).toBe(200);
});
it('토큰 없이 접근 시 401', async () => {
const res = await request(app)
.get('/profile');
expect(res.status).toBe(401);
});
});
FAQ
Q1. JWT를 어디에 저장해야 하나요?
- localStorage: 편리하지만 XSS 취약
- HttpOnly Cookie: 더 안전하지만 CSRF 방어 필요
- 추천: HttpOnly Cookie + CSRF 토큰
Q2. Access Token 만료 시간은 얼마가 적당한가요?
- 일반적: 15분~1시간
- 민감한 서비스: 5~15분
- 덜 민감한 서비스: 1~24시간
Q3. Refresh Token은 어디에 저장하나요?
- DB에 저장 (강제 로그아웃 가능)
- Redis에 저장 (빠른 조회)
Q4. OAuth 2.0과 JWT를 함께 쓰나?
흔한 패턴이다. IdP에서 인가 코드로 사용자 정보를 받은 뒤, 우리 서비스 세션·JWT를 발급해 쓴다.
app.get('/auth/google/callback',
passport.authenticate('google'),
(req, res) => {
// OAuth 로그인 성공 후 JWT 발급
const token = jwt.sign({ userId: req.user.id }, SECRET);
res.json({ token });
}
);
요약
핵심 정리
JWT:
- 토큰 기반 인증
- Stateless, 확장성 좋음
- Access Token + Refresh Token 조합
OAuth 2.0:
- 소셜 로그인
- 제3자 인증 위임
- 사용자 편의성
보안:
- HTTPS 필수
- HttpOnly 쿠키
- 짧은 만료 시간
- Rate Limiting
다음 글 추천
- Node.js 보안 가이드
- API 보안 체크리스트
- HTTPS·SSL/TLS 정리
키워드: JWT, OAuth, 인증, Authentication, Token, Access Token, Refresh Token, OAuth 2.0, 보안, Security, 소셜 로그인