JWT & OAuth 2.0 인증 실전 가이드 | 토큰 기반 인증
이 글의 핵심
JWT & OAuth 2.0 인증 실전 가이드에 대해 정리한 개발 블로그 글입니다. JWT로 상태 없이 검증하는 흐름과, OAuth로 외부 로그인을 붙인 뒤 우리 서비스 토큰을 발급하는 흐름을 예제로 정리한다. 이론 체크리스트보다는 배포에서 자주 묻는 지점 위주다. --- 인증… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다. 관련 키워드: JWT,…
이 글의 핵심
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에서 사용자 정보 추출
JWT 내부 동작 심화 (JWS·검증·클레임·갱신)
실무에서 말하는 JWT 문자열은 RFC 7519 클레임을 RFC 7515 JWS로 만든 컴팩트 서명이다. 서명은 base64url(header) + "." + base64url(payload)에 대해 alg(예: HS256, RS256)로 계산된다. decode만으로는 위조 여부를 알 수 없다 — 반드시 같은 alg와 키로 서명을 재검증한 뒤 exp/nbf/iat를 확인해야 한다.
HS256은 대칭 키를 발급·검증에 공유하고, RS256은 발급자만 개인키를 쓰고 검증자는 공개키(JWKS 등)만 받는다. OAuth/OIDC 연동 시 iss·aud를 검증하지 않으면 다른 리소스 서버용으로 발급된 토큰을 잘못 수용할 수 있다. jti는 토큰별 고유 ID로, Refresh 회전·재사용 탐지·denylist와 맞물리면 로그아웃·탈취 대응에 유리하다. 아래 절의 Node 예제는 이 전제 위에서 jwt.sign / jwt.verify를 사용한다.
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());
OAuth 구현하면서 겪은 것들
OAuth 붙인다고 문서대로만 하면 되는 줄 알았는데, 첫 프로덕션에서는 거의 항상 리다이렉트 URI 한 글자나 state 빼먹은 것 때문에 밤을 샌다. 나는 로컬에선 됐는데 스테이징에서만 redirect_uri_mismatch 나는 날, 콘솔에 등록한 URL과 실제 콜백이 미세하게 다른 걸 찾느라 로그를 뒤졌다. 그리고 결론을 하나 박아 둔다. PKCE는 선택이 아니라 필수다. SPA·모바일·공개 클라이언트면 더더욱, 인가 코드만 믿고 가면 가로채기 시나리오에 너무 쉽게 열린다. 서버 쪽 기밀 클라이언트라고 해서 “우린 시크릿 있으니까” 하고 PKCE를 건너뛰는 팀도 봤는데, 난 이제 새 코드 플로우는 무조건 PKCE부터 깔고 본다. OAuth 2.1 쪽 흐름이 그쪽으로 기울어 있고, 구현해놓고 나면 디버깅할 때도 마음이 편하다. 아래는 그때 정리해둔 플로우·용어 쪽이다. 표로 암기하려다 말고, 한 번 브라우저 네트워크 탭으로 authorize → 콜백 → token 순서를 직접 밟아보면 훨씬 빨리 몸에 붙는다.
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 (액세스 토큰)
- 리소스 접근 권한
인가 코드(Authorization Code) 플로우 — 내부 동작
위 다이어그램은 사실상 인가 코드 부여(Authorization Code Grant) 흐름이다. OAuth 2.0에서 “로그인 버튼 → 구글 → 다시 우리 앱”으로 돌아오는 구간을 프론트 채널(브라우저 리다이렉트) 이라 부르고, 인증 코드를 액세스 토큰으로 바꾸는 HTTP POST 는 백 채널(서버 간) 에서 이루어지는 경우가 많다. 이 분리가 핵심이다.
왜 리다이렉트 URL에 바로 액세스 토큰을 실어 보내지 않는가?
히스토리·호환 이슈도 있지만, 실무적으로는 토큰이 URL·브라우저 기록·Referer 에 노출될 위험을 줄이기 위해 짧게 살아 있는 일회성 코드만 프론트에 넘기고, 클라이언트 비밀(client secret) 이 있을 수 있는 백엔드에서 토큰 엔드포인트로 교환하는 패턴이 널리 쓰인다. 공개 클라이언트(모바일·순수 SPA)는 시크릿을 안전하게 숨길 수 없으므로 PKCE로 같은 위협을 완화한다(아래 참고).
전형적인 단계 (RFC 6749 기준 개념):
- 인가 요청: 클라이언트가 사용자 브라우저를 인가 엔드포인트로 보낸다.
response_type=code로 코드를 요청한다. - 사용자 인증·동의: 인증 서버가 로그인·동의 화면을 보여 준다.
- 리다이렉트: 승인되면
redirect_uri로 돌아오며 쿼리에code가 붙는다. 동시에 CSRF 방지용state를 원래 보냈다면 그대로 돌려받아 검증한다. - 토큰 요청: 클라이언트(보통 백엔드)가 토큰 엔드포인트에
grant_type=authorization_code로code,redirect_uri, 클라이언트 식별 정보 등을 보내 액세스 토큰(및 경우에 따라 리프레시 토큰)을 받는다. - 리소스 접근: 액세스 토큰으로 리소스 서버(또는 사용자 정보 엔드포인트)를 호출한다.
클라이언트 유형도 같이 이해해야 한다. 기밀(confidential) 클라이언트는 서버에서 동작하며 client secret 을 숨길 수 있고, 공개(public) 클라이언트는 시크릿 없이 동작하므로 PKCE·리다이렉트 URI 엄격 일치 같은 보완이 필수에 가깝다.
PKCE(Proof Key for Code Exchange)
PKCE는 인가 코드가 가로채였을 때 공격자가 그 코드로 토큰을 받아가는 상황을 어렵게 만든다. 코드 검증기(code_verifier) 를 클라이언트가 만들고, 그로부터 코드 챌린지(code_challenge) 만 인가 요청에 넣는다. 토큰 교환 시에는 원본 code_verifier 를 토큰 엔드포인트에 보내 검증한다.
흐름 요약:
- code_verifier: 길고 예측하기 어려운 난수 문자열(일반적으로 43–128자 범위의 URL-safe 문자).
- code_challenge:
BASE64URL(SHA256(code_verifier))(S256 방법이 사실상 표준). - 인가 요청 시
code_challenge와code_challenge_method=S256를 전달한다. - 토큰 요청 시
code_verifier를 함께 보내면, 인증 서버가 동일한 해시인지 확인한다.
브라우저 기반 SPA·모바일 앱처럼 client secret 을 둘 수 없는 환경에서 인가 코드 플로우 + PKCE 는 “권장” 수준이 아니라, 나는 그냥 필수라고 본다. 백엔드만 있고 시크릿이 있다고 해도, 새로 짜는 인가 코드 플로우에는 PKCE를 기본으로 넣는 쪽이 이후 감사·보안 질문에도 답이 된다. OAuth 2.1 은 새 구현에 PKCE를 사실상 기대하는 쪽으로 정리되어 있다.
토큰 엔드포인트(Token Endpoint)
토큰 엔드포인트는 액세스 토큰(및 선택적으로 리프레시 토큰)을 발급·갱신하는 HTTPS 전용 API 이다. 인가 코드를 받은 뒤의 첫 교환뿐 아니라, grant_type=refresh_token 으로 액세스 토큰 갱신도 여기서 처리한다.
인가 코드 교환 시(개념적 파라미터):
grant_type:authorization_codecode: 리다이렉트로 받은 일회성 코드redirect_uri: 인가 요청 때 사용한 것과 동일해야 하는 경우가 많다client_id: 등록된 클라이언트 ID- 기밀 클라이언트:
client_secret또는 HTTP Basic 으로 클라이언트 인증 - PKCE 사용 시:
code_verifier
응답은 보통 JSON 이며 access_token, token_type(보통 Bearer), expires_in 을 포함한다. OpenID Connect 를 쓰면 id_token 이 추가되기도 한다. 에러는 error, error_description 으로 돌아오므로, 프로덕션에서는 이를 로깅·알림에 포함하되 시크릿·코드 값은 마스킹한다.
스코프(Scope)와 동의(Consent)
스코프는 “이 토큰으로 무엇을 할 수 있는지”를 문자열로 표현한 것이다. 공백으로 구분된 목록으로 전달되는 경우가 많고, 제공자마다 정의한 권한 집합(예: 프로필 읽기, 이메일, 캘린더)에 대응한다.
동의 화면은 사용자가 해당 스코프에 실제로 동의했는지를 확보하는 단계다. 최초 로그인 때 많은 권한을 한꺼번에 요구하면 이탈이 늘 수 있어, 필요한 최소 스코프만 요청하고 점진적 인가(incremental authorization) 로 나중에 추가 권한을 요청하는 전략이 권장된다. 이미 부여된 스코프는 세션에 따라 동의 화면을 생략할 수도 있다.
애플리케이션 설계 시 “우리 서비스의 RBAC” 과 “IdP 가 부여한 OAuth 스코프” 를 혼동하지 않는 것이 중요하다. 소셜 로그인 후에는 보통 우리 쪽 사용자 레코드와 우리 API용 JWT/세션을 별도로 발급해 권한을 관리한다.
프로덕션 OAuth 패턴
실서비스에서 반복되는 패턴을 정리하면 다음과 같다.
- 리다이렉트 URI 엄격 일치: 스킴·호스트·포트·경로·슬래시까지 등록값과 완전히 동일하게 맞춘다.
redirect_uri_mismatch는 대부분 여기서 발생한다. state+ PKCE:state로 CSRF 를 막고, 공개 클라이언트는 PKCE 로 코드 탈취 완화.- 토큰 저장소: 브라우저에서는 가능하면 HttpOnly·Secure·SameSite 쿠키 등으로 XSS 노출면을 줄이고, SPA 는 BFF(백엔드 포 프론트) 패턴으로 토큰을 서버 측에 두는 선택도 흔하다.
- 짧은 액세스 토큰 + 리프레시·로테이션: 탈취 윈도우를 줄이고, 리프레시 토큰 재사용 탐지 등으로 계정 탈취에 대응한다.
- OIDC 사용 시:
id_token은 JWT 이므로 서명 검증에 JWKS 엔드포인트를 사용하고,nonce로 리플레이를 완화한다. - 관측 가능성: 인가 실패·토큰 엔드포인트 오류율·지연을 대시보드에 올려 고객 지원 문의와 상관분석한다.
이 절의 내용은 Passport 같은 라이브러리가 내부에서 대신해 주는 부분이 많지만, 장애 분석·보안 감사·새 플랫폼(모바일·데스크톱 딥링크) 연동 시에는 위 개념을 직접 알아야 원인을 빠르게 좁힐 수 있다.
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, 표 말고 감으로 고르기
표로 갈지 말지 비교해보면 막막하다. 내 기준은 이렇다. 전통 웹(서버 렌더) 이고 서버가 세션을 쥐고 있어도 되면 세션이 직관적이고, 강제 로그아웃도 잘 풀린다. API·모바일·수평 확장이 핵심이면 JWT 쪽이 덜 끈적이고, 대신 토큰 보관·만료·탈취 대응이 숙제다. 구글/카카오로 로그인 붙이거나 다른 서비스 리소스를 사용자 대리로 쓰면 그게 곧 OAuth 영역이고, 여기서는 “우리 서비스 권한”이랑 IdP 스코프를 섞지 말고 로그인 후 우리 쪽 토큰/세션 따로 두는 쪽이 정신 건강에 이롭다. 복잡도는 OAuth가 제일 올라가는데, 그만큼 운영에서 묻는 건 리다이렉트·state·PKCE 같은 실전 질문이다. 암기용 표는 없앴으니, 위에 적은 OAuth 구현하면서 단락이랑 같이 보면 된다.
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, 소셜 로그인
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「JWT & OAuth 2.0 인증 실전 가이드 | 토큰 기반 인증」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「JWT & OAuth 2.0 인증 실전 가이드 | 토큰 기반 인증」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- Node.js 인증과 보안 | JWT, bcrypt, 세션, OAuth
- JWT 인증 완벽 가이드 | 원리·구현·보안·Refresh Token·실전 예제
- [OAuth 2.0 & JWT authentication — token login, refresh flows,](/en/blog/oauth-2-complete-guide/
이 글에서 다루는 키워드 (관련 검색어)
JWT, OAuth, 인증, Authentication, Token, Security, 보안, Access Token, Refresh Token, OAuth 2.0 등으로 검색하시면 이 글이 도움이 됩니다.