PKCE 완벽 마스터
PKCE의 핵심 개념과 실전 활용법
학습 항목
이미지 로딩 중...
OAuth 2.0 보안 베스트 프랙티스 완벽 가이드
현대 웹 애플리케이션의 필수 인증 프로토콜인 OAuth 2.0의 보안 취약점과 해결 방법을 실무 중심으로 다룹니다. PKCE, State 파라미터, 토큰 관리 등 실전에서 반드시 알아야 할 보안 베스트 프랙티스를 초급 개발자도 쉽게 이해할 수 있도록 설명합니다.
목차
- PKCE (Proof Key for Code Exchange)
- State 파라미터
- 토큰 저장소 보안
- Refresh Token 로테이션
- Redirect URI 검증
- Scope 최소 권한 원칙
- HTTPS 강제 사용
- 토큰 만료 시간 설정
1. PKCE (Proof Key for Code Exchange)
시작하며
여러분이 모바일 앱이나 SPA(Single Page Application)에서 OAuth 2.0을 구현할 때 이런 상황을 겪어본 적 있나요? 사용자가 로그인을 완료했는데, 악의적인 공격자가 인증 코드를 가로채서 여러분의 앱인 것처럼 위장해 토큰을 발급받는 상황입니다.
이런 문제는 특히 클라이언트 시크릿을 안전하게 저장할 수 없는 퍼블릭 클라이언트(모바일 앱, SPA)에서 자주 발생합니다. 공격자는 앱을 디컴파일하거나 브라우저 개발자 도구를 통해 시크릿을 쉽게 추출할 수 있고, 이를 이용해 인증 코드를 탈취하면 사용자 계정에 무단으로 접근할 수 있습니다.
바로 이럴 때 필요한 것이 PKCE(Proof Key for Code Exchange)입니다. PKCE는 매 인증 요청마다 임의의 검증 키를 생성하여, 인증 코드를 발급받은 클라이언트만 토큰을 받을 수 있도록 보장합니다.
개요
간단히 말해서, PKCE는 인증 코드 교환 과정에서 "증명 키"를 사용하여 요청자의 신원을 검증하는 보안 메커니즘입니다. 전통적인 OAuth 2.0 플로우에서는 클라이언트 시크릿만으로 인증하지만, 퍼블릭 클라이언트는 시크릿을 안전하게 보관할 수 없다는 근본적인 문제가 있습니다.
예를 들어, React로 만든 SPA나 React Native 모바일 앱에서는 번들 파일을 분석하면 시크릿이 그대로 노출됩니다. 기존에는 이 문제를 해결하기 위해 Implicit Grant를 사용했다면, 이제는 PKCE를 적용한 Authorization Code Grant를 사용하는 것이 표준입니다.
PKCE의 핵심 특징은 세 가지입니다. 첫째, 매 요청마다 랜덤한 Code Verifier를 생성합니다.
둘째, 이를 SHA-256으로 해싱한 Code Challenge를 인증 요청에 포함시킵니다. 셋째, 토큰 교환 시 원본 Code Verifier를 제출하여 검증받습니다.
이러한 특징들이 중요한 이유는 공격자가 인증 코드를 가로채더라도 원본 Code Verifier 없이는 토큰을 받을 수 없기 때문입니다.
코드 예제
// PKCE를 사용한 OAuth 2.0 인증 플로우
import crypto from 'crypto';
// 주석: Code Verifier 생성 - 43~128자의 랜덤 문자열
function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
// 주석: Code Challenge 생성 - Verifier를 SHA-256으로 해싱
function generateCodeChallenge(verifier) {
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
// 주석: 인증 요청 시 Code Challenge 포함
const authUrl = `https://oauth.example.com/authorize?` +
`client_id=YOUR_CLIENT_ID&` +
`redirect_uri=https://yourapp.com/callback&` +
`response_type=code&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256`;
// 주석: 토큰 교환 시 원본 Code Verifier 제출
async function exchangeToken(authCode) {
const response = await fetch('https://oauth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: 'https://yourapp.com/callback',
client_id: 'YOUR_CLIENT_ID',
code_verifier: codeVerifier // 주석: 서버가 이를 해싱하여 Challenge와 비교
})
});
return response.json();
}
설명
이것이 하는 일: PKCE는 인증 코드가 중간에 탈취되더라도 공격자가 토큰을 발급받지 못하도록 막는 보안 레이어를 추가합니다. 첫 번째로, 클라이언트 앱이 OAuth 인증을 시작할 때 43~128자 길이의 랜덤한 Code Verifier를 생성합니다.
이 값은 암호학적으로 안전한 난수 생성기를 사용해야 하며, 클라이언트 측에만 저장되고 절대 외부로 전송되지 않습니다. 그런 다음 이 Verifier를 SHA-256으로 해싱하여 Code Challenge를 만들고, 이를 인증 서버에 전송합니다.
왜 이렇게 하는지 궁금하시죠? 해시 함수는 일방향이기 때문에 Challenge를 봐도 원본 Verifier를 알아낼 수 없습니다.
그 다음으로, 사용자가 인증을 완료하면 인증 서버가 인증 코드를 발급하면서 해당 코드와 Code Challenge를 연결하여 저장합니다. 이 단계에서 만약 공격자가 네트워크를 감청하여 인증 코드를 가로챈다 하더라도, 공격자는 Code Challenge만 볼 수 있을 뿐 원본 Verifier는 알 수 없습니다.
내부에서는 서버가 "이 인증 코드는 이 Challenge와 함께 발급되었다"는 정보를 안전하게 보관합니다. 마지막으로, 클라이언트가 인증 코드를 받아 토큰을 요청할 때 원본 Code Verifier를 함께 제출합니다.
서버는 이 Verifier를 SHA-256으로 해싱한 후, 이전에 저장했던 Code Challenge와 일치하는지 검증합니다. 일치하면 토큰을 발급하고, 일치하지 않으면 요청을 거부합니다.
최종적으로 정당한 클라이언트만 토큰을 받을 수 있게 됩니다. 여러분이 이 코드를 사용하면 모바일 앱이나 SPA에서 클라이언트 시크릿 없이도 안전하게 OAuth 2.0을 구현할 수 있습니다.
인증 코드 탈취 공격을 원천 차단할 수 있고, 공격자가 중간에 인증 코드를 가로채더라도 토큰을 발급받을 수 없으며, OAuth 2.0 Security Best Current Practice(RFC 8252)를 준수하여 보안 감사를 통과할 수 있습니다.
실전 팁
💡 Code Verifier는 반드시 crypto.randomBytes()나 window.crypto.getRandomValues() 같은 암호학적으로 안전한 난수 생성기를 사용하세요. Math.random()은 예측 가능하므로 절대 사용하면 안 됩니다.
💡 Code Verifier를 세션 스토리지나 메모리에 임시 저장하되, 토큰 교환이 완료되면 즉시 삭제하세요. 로컬 스토리지에 장기 보관하면 XSS 공격에 취약해집니다.
💡 모든 주요 OAuth 제공자(Google, GitHub, Auth0 등)가 PKCE를 지원하므로, 새로운 프로젝트에서는 반드시 PKCE를 활성화하세요. code_challenge_method는 'S256'을 사용하는 것이 보안상 더 안전합니다.
💡 OAuth 라이브러리를 사용할 때 PKCE가 자동으로 활성화되어 있는지 확인하세요. 예를 들어, @auth0/auth0-spa-js는 기본적으로 PKCE를 사용하지만, 일부 오래된 라이브러리는 수동 설정이 필요할 수 있습니다.
💡 토큰 교환 요청이 실패하면 Code Verifier를 재사용하지 말고 새로운 인증 플로우를 시작하세요. 같은 Verifier를 여러 번 사용하면 재사용 공격(replay attack)에 노출될 수 있습니다.
2. State 파라미터
시작하며
여러분이 OAuth 로그인 버튼을 클릭했을 때 이런 상황을 겪어본 적 있나요? 사용자가 정상적으로 로그인을 완료했는데, 공격자가 미리 준비한 악성 계정으로 로그인되어 있는 상황입니다.
이것은 CSRF(Cross-Site Request Forgery) 공격의 전형적인 예시입니다. 이런 문제는 OAuth 콜백 엔드포인트가 요청의 출처를 검증하지 않을 때 발생합니다.
공격자는 자신의 계정으로 OAuth 인증을 시작한 후, 그 콜백 URL을 피해자에게 보냅니다. 피해자가 그 링크를 클릭하면 공격자의 계정으로 로그인되고, 피해자가 입력한 민감한 정보가 공격자의 계정에 저장되는 심각한 보안 문제가 발생합니다.
바로 이럴 때 필요한 것이 State 파라미터입니다. State는 암호학적으로 안전한 랜덤 값을 생성하여 인증 요청과 콜백을 연결함으로써, 요청이 정당한 출처에서 시작되었는지 검증합니다.
개요
간단히 말해서, State 파라미터는 OAuth 인증 플로우에서 요청의 무결성을 보장하고 CSRF 공격을 방지하는 보안 토큰입니다. 전통적인 웹 애플리케이션에서는 CSRF 토큰을 폼에 포함시켜 검증하지만, OAuth 플로우는 여러 도메인을 거치면서 진행되므로 일반적인 CSRF 방어 메커니즘이 작동하지 않습니다.
예를 들어, 여러분의 앱(example.com)에서 시작한 요청이 OAuth 제공자(oauth.provider.com)를 거쳐 다시 돌아오는 과정에서, 공격자가 중간에 끼어들 수 있는 여지가 생깁니다. 기존에는 세션 쿠키만으로 사용자를 식별했다면, 이제는 State 파라미터를 추가하여 "이 콜백이 정말 우리가 시작한 인증 플로우의 결과인가?"를 검증할 수 있습니다.
State 파라미터의 핵심 특징은 세 가지입니다. 첫째, 매 인증 요청마다 예측 불가능한 랜덤 값을 생성합니다.
둘째, 이 값을 인증 요청에 포함시키면 OAuth 제공자가 콜백 시 그대로 반환합니다. 셋째, 콜백을 받을 때 원본 State 값과 비교하여 일치 여부를 확인합니다.
이러한 특징들이 중요한 이유는 공격자가 임의로 만든 콜백 URL은 올바른 State 값을 포함할 수 없기 때문입니다.
코드 예제
// State 파라미터를 사용한 CSRF 방지
import crypto from 'crypto';
// 주석: 암호학적으로 안전한 랜덤 State 생성
function generateState() {
return crypto.randomBytes(32).toString('hex');
}
// 주석: 인증 시작 시 State 생성 및 저장
export async function initiateOAuth(req, res) {
const state = generateState();
// 주석: State를 세션이나 쿠키에 저장 (httpOnly, secure 옵션 필수)
req.session.oauthState = state;
const authUrl = `https://oauth.example.com/authorize?` +
`client_id=YOUR_CLIENT_ID&` +
`redirect_uri=https://yourapp.com/callback&` +
`response_type=code&` +
`state=${state}&` + // 주석: State를 URL에 포함
`scope=read:user`;
res.redirect(authUrl);
}
// 주석: 콜백 처리 시 State 검증
export async function handleCallback(req, res) {
const { code, state } = req.query;
const savedState = req.session.oauthState;
// 주석: State 불일치 시 요청 거부
if (!state || state !== savedState) {
return res.status(403).json({ error: 'Invalid state parameter' });
}
// 주석: 검증 완료 후 State 삭제 (재사용 방지)
delete req.session.oauthState;
// 주석: 토큰 교환 진행
const token = await exchangeCodeForToken(code);
res.json({ success: true, token });
}
설명
이것이 하는 일: State 파라미터는 OAuth 인증 플로우의 시작과 끝을 연결하는 보안 끈(security tie)으로, 외부에서 조작된 콜백 요청을 탐지하고 차단합니다. 첫 번째로, 사용자가 "소셜 로그인" 버튼을 클릭하면 서버가 crypto.randomBytes()를 사용하여 32바이트의 암호학적으로 안전한 랜덤 값을 생성합니다.
이 값은 16진수 문자열로 변환되어 64자 길이의 State가 되며, 이를 세션 스토어나 암호화된 쿠키에 저장합니다. 왜 이렇게 하는지는 간단합니다.
콜백이 돌아올 때 비교할 원본 값이 필요하기 때문이죠. 이 State를 인증 URL의 쿼리 파라미터로 포함시켜 OAuth 제공자로 리다이렉트합니다.
그 다음으로, 사용자가 OAuth 제공자에서 로그인을 완료하면 제공자는 인증 코드와 함께 우리가 보냈던 State 값을 그대로 콜백 URL에 포함시켜 돌려보냅니다. OAuth 제공자는 State 값을 전혀 수정하지 않고 단순히 "에코(echo)"하는 역할만 합니다.
내부에서는 이 과정이 HTTP 리다이렉트를 통해 이루어지며, 브라우저가 자동으로 콜백 URL로 이동합니다. 마지막으로, 콜백 엔드포인트에서 URL 쿼리 파라미터로 받은 State와 세션에 저장했던 원본 State를 비교합니다.
두 값이 정확히 일치하면 이 요청이 우리가 시작한 정당한 인증 플로우의 결과임을 확인할 수 있습니다. 일치하지 않거나 State가 아예 없다면 CSRF 공격일 가능성이 높으므로 즉시 요청을 거부합니다.
최종적으로 검증이 완료되면 세션에서 State를 삭제하여 재사용 공격을 방지합니다. 여러분이 이 코드를 사용하면 CSRF 공격으로부터 사용자를 완벽하게 보호할 수 있습니다.
공격자가 피해자를 자신의 계정으로 로그인시키는 것을 방지할 수 있고, 중간자가 콜백 URL을 조작하려는 시도를 탐지할 수 있으며, OWASP Top 10 보안 권고사항을 준수하여 보안 취약점 스캔을 통과할 수 있습니다.
실전 팁
💡 State 값은 최소 32바이트(256비트)의 랜덤 값을 사용하세요. 너무 짧으면 브루트포스 공격으로 추측될 수 있습니다. 일반적으로 32~64자 길이의 16진수 문자열이 적합합니다.
💡 State를 저장할 때 httpOnly와 secure 플래그가 설정된 쿠키를 사용하거나 서버 측 세션 스토어를 활용하세요. 클라이언트 측 JavaScript에서 접근 가능한 곳에 저장하면 XSS 공격에 취약해집니다.
💡 State에 타임스탬프를 포함시켜 만료 시간을 설정하는 것도 좋은 방법입니다. 예를 들어, {randomValue}.{timestamp} 형식으로 생성하고 10분 이상 된 State는 거부하여 오래된 콜백 URL 재사용을 방지할 수 있습니다.
💡 State 검증 실패 시 자세한 오류 메시지를 반환하지 마세요. "Invalid state parameter"처럼 간단한 메시지만 보내고, 상세한 로그는 서버에만 기록하여 공격자에게 정보를 주지 않도록 합니다.
💡 OAuth 라이브러리를 사용하더라도 State 검증이 제대로 작동하는지 테스트하세요. 일부 라이브러리는 State 생성은 하지만 검증을 건너뛰는 경우가 있으므로, 실제로 잘못된 State를 보내봐서 거부되는지 확인해야 합니다.
3. 토큰 저장소 보안
시작하며
여러분이 OAuth로 받은 액세스 토큰을 어디에 저장해야 할지 고민해본 적 있나요? 로컬 스토리지에 저장했더니 XSS(Cross-Site Scripting) 공격으로 토큰이 탈취되거나, 쿠키에 저장했더니 CSRF 공격에 노출되는 상황입니다.
실제로 많은 개발자들이 이 부분에서 실수를 합니다. 이런 문제는 토큰의 민감성을 과소평가하고 편의성만 고려할 때 발생합니다.
액세스 토큰은 사용자의 계정에 접근할 수 있는 열쇠와 같으므로, 잘못 저장하면 공격자가 사용자 데이터를 훔치거나 계정을 탈취할 수 있습니다. 특히 SPA(Single Page Application)에서는 브라우저에 토큰을 저장해야 하는데, 각 저장 방식마다 서로 다른 보안 위험이 존재합니다.
바로 이럴 때 필요한 것이 올바른 토큰 저장소 보안 전략입니다. httpOnly 쿠키, 메모리 저장, BFF(Backend For Frontend) 패턴 등 상황에 맞는 최적의 방법을 선택하고 구현해야 합니다.
개요
간단히 말해서, 토큰 저장소 보안은 액세스 토큰과 리프레시 토큰을 XSS와 CSRF 공격으로부터 안전하게 보호하는 저장 및 전송 메커니즘입니다. 브라우저 환경에서 토큰을 저장하는 방법은 크게 세 가지입니다: 로컬 스토리지, 세션 스토리지, 쿠키.
각각의 방법은 장단점이 있지만, 보안 관점에서 보면 로컬/세션 스토리지는 JavaScript로 접근 가능하므로 XSS 공격에 매우 취약합니다. 예를 들어, 여러분의 사이트에 악성 스크립트가 주입되면 localStorage.getItem('access_token')으로 즉시 토큰을 훔칠 수 있습니다.
기존에는 로컬 스토리지에 토큰을 저장하고 매 요청마다 Authorization 헤더에 포함시켰다면, 이제는 httpOnly 쿠키를 사용하거나 BFF 패턴을 도입하여 토큰을 프론트엔드에서 완전히 격리하는 것이 권장됩니다. 토큰 저장소 보안의 핵심 특징은 네 가지입니다.
첫째, httpOnly 플래그를 사용하여 JavaScript에서 쿠키에 접근하지 못하도록 차단합니다. 둘째, Secure 플래그로 HTTPS에서만 쿠키를 전송하도록 강제합니다.
셋째, SameSite 속성으로 CSRF 공격을 방어합니다. 넷째, 짧은 만료 시간과 리프레시 토큰 로테이션으로 토큰 탈취 피해를 최소화합니다.
이러한 특징들이 중요한 이유는 다층 방어(defense in depth) 전략을 통해 한 가지 보안 메커니즘이 뚫려도 다른 메커니즘이 보호할 수 있기 때문입니다.
코드 예제
// httpOnly 쿠키를 사용한 안전한 토큰 저장
import express from 'express';
const app = express();
// 주석: 토큰을 httpOnly 쿠키에 저장하는 함수
export function setTokenCookie(res, accessToken, refreshToken) {
// 주석: 액세스 토큰 - 짧은 만료 시간 (15분)
res.cookie('access_token', accessToken, {
httpOnly: true, // 주석: JavaScript 접근 차단
secure: true, // 주석: HTTPS에서만 전송
sameSite: 'strict', // 주석: CSRF 방어
maxAge: 15 * 60 * 1000 // 주석: 15분 후 만료
});
// 주석: 리프레시 토큰 - 긴 만료 시간 (7일), 별도 경로
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 주석: 7일
path: '/api/auth/refresh' // 주석: 특정 경로에서만 전송
});
}
// 주석: 보호된 API 엔드포인트 - 쿠키에서 토큰 자동 읽기
app.get('/api/user/profile', (req, res) => {
const token = req.cookies.access_token;
// 주석: 토큰 검증 (JWT 라이브러리 사용)
if (!token || !verifyToken(token)) {
return res.status(401).json({ error: 'Unauthorized' });
}
// 주석: 검증 성공 시 사용자 데이터 반환
res.json({ user: getUserFromToken(token) });
});
// 주석: 로그아웃 시 쿠키 삭제
app.post('/api/auth/logout', (req, res) => {
res.clearCookie('access_token');
res.clearCookie('refresh_token', { path: '/api/auth/refresh' });
res.json({ success: true });
});
설명
이것이 하는 일: 토큰을 JavaScript가 접근할 수 없는 httpOnly 쿠키에 저장하고, 여러 보안 플래그를 조합하여 다층 방어 체계를 구축합니다. 첫 번째로, 서버가 OAuth 제공자로부터 토큰을 받으면 이를 httpOnly 쿠키에 저장합니다.
httpOnly 플래그가 설정된 쿠키는 document.cookie로 접근할 수 없으며, JavaScript의 어떤 API로도 읽을 수 없습니다. 따라서 공격자가 XSS 취약점을 통해 악성 스크립트를 주입하더라도 토큰을 훔칠 수 없습니다.
또한 Secure 플래그를 함께 설정하여 HTTPS 연결에서만 쿠키가 전송되도록 보장합니다. 왜 이렇게 하는지는 명확합니다.
HTTP로 전송되면 중간자 공격으로 쿠키를 가로챌 수 있기 때문입니다. 그 다음으로, SameSite 속성을 'strict' 또는 'lax'로 설정하여 CSRF 공격을 방어합니다.
SameSite='strict'는 다른 사이트에서 시작된 요청에 쿠키를 전혀 포함시키지 않으므로, 공격자가 악성 사이트에서 여러분의 API를 호출하더라도 토큰이 전송되지 않습니다. 내부에서는 브라우저가 요청의 출처를 확인하고, 같은 사이트 내에서 시작된 요청에만 쿠키를 포함시킵니다.
액세스 토큰과 리프레시 토큰은 별도로 관리되며, 리프레시 토큰은 특정 경로(/api/auth/refresh)에서만 전송되도록 path 옵션을 설정하여 노출 범위를 최소화합니다. 마지막으로, 클라이언트가 보호된 API를 호출할 때 브라우저가 자동으로 쿠키를 포함시키므로, 프론트엔드 코드에서 토큰을 직접 다룰 필요가 없습니다.
서버는 req.cookies에서 토큰을 읽어 검증하고, 유효하면 요청을 처리합니다. 토큰이 만료되면 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받고, 로그아웃 시에는 clearCookie()로 쿠키를 삭제합니다.
최종적으로 토큰이 프론트엔드 JavaScript의 접근 범위를 완전히 벗어나 있으므로 XSS 공격으로부터 안전합니다. 여러분이 이 코드를 사용하면 XSS 취약점이 존재하더라도 토큰 탈취를 방지할 수 있습니다.
JavaScript에서 토큰에 접근할 수 없으므로 악성 스크립트가 무력화되고, SameSite 속성으로 CSRF 공격도 동시에 차단하며, 토큰 만료 시간을 짧게 설정하여(15분) 탈취된 토큰의 유효 기간을 최소화할 수 있습니다. 또한 리프레시 토큰을 별도로 관리하여 장기 인증을 안전하게 유지할 수 있습니다.
실전 팁
💡 SameSite 속성은 'strict'가 가장 안전하지만, 외부 사이트에서 링크로 들어올 때 문제가 생길 수 있으므로 'lax'를 사용하는 것도 고려하세요. 'lax'는 GET 요청에만 쿠키를 포함시키므로 대부분의 CSRF 공격을 막으면서도 사용성을 유지합니다.
💡 CORS 설정 시 credentials: 'include' 옵션을 사용해야 쿠키가 크로스 오리진 요청에 포함됩니다. 서버에서는 Access-Control-Allow-Credentials: true 헤더를 반환하고, Access-Control-Allow-Origin은 와일드카드(*)가 아닌 구체적인 도메인을 지정해야 합니다.
💡 BFF(Backend For Frontend) 패턴을 고려하세요. 프론트엔드와 같은 도메인에 있는 백엔드 서버가 토큰을 관리하고, 프론트엔드는 토큰을 전혀 다루지 않는 방식입니다. 이렇게 하면 토큰이 브라우저를 완전히 벗어나므로 가장 안전합니다.
💡 토큰을 메모리에만 저장하는 방법도 있지만, 페이지를 새로고침하면 토큰이 사라지므로 사용자 경험이 나빠집니다. 이 경우 리프레시 토큰을 httpOnly 쿠키에 저장하고, 페이지 로드 시 자동으로 새 액세스 토큰을 발급받는 방식으로 해결할 수 있습니다.
💡 개발 환경에서는 secure 플래그가 localhost를 차단할 수 있으므로, 환경 변수를 사용하여 프로덕션에서만 활성화하세요. 예: secure: process.env.NODE_ENV === 'production'
4. Refresh Token 로테이션
시작하며
여러분이 리프레시 토큰을 사용하여 장기 인증을 구현했을 때 이런 상황을 겪어본 적 있나요? 공격자가 리프레시 토큰을 탈취한 후, 여러분의 서비스에 무한정 접근할 수 있는 상황입니다.
리프레시 토큰은 일반적으로 수개월간 유효하므로, 한 번 탈취되면 장기간 피해가 지속됩니다. 이런 문제는 리프레시 토큰을 "일회성 사용"이 아닌 "재사용 가능한 장기 토큰"으로 설계할 때 발생합니다.
전통적인 방식에서는 같은 리프레시 토큰을 계속 사용하므로, 정당한 사용자와 공격자를 구별할 수 없습니다. 공격자가 리프레시 토큰을 한 번 훔치면 사용자와 동시에 새로운 액세스 토큰을 발급받으면서 계정에 접근할 수 있습니다.
바로 이럴 때 필요한 것이 Refresh Token Rotation(리프레시 토큰 로테이션)입니다. 매번 리프레시 토큰을 사용할 때마다 새로운 리프레시 토큰을 발급하고 이전 것을 무효화하여, 토큰 재사용을 탐지하고 차단합니다.
개요
간단히 말해서, Refresh Token Rotation은 리프레시 토큰을 사용할 때마다 새로운 토큰을 발급하고 이전 토큰을 즉시 폐기하여, 토큰 탈취를 탐지하고 피해를 최소화하는 보안 메커니즘입니다. 리프레시 토큰의 주요 목적은 사용자가 매번 로그인하지 않고도 새로운 액세스 토큰을 받을 수 있도록 하는 것입니다.
하지만 리프레시 토큰이 장기간 유효하고 재사용 가능하면 심각한 보안 위험이 됩니다. 예를 들어, 공용 Wi-Fi에서 리프레시 토큰이 탈취되거나, 클라이언트 디바이스가 분실되었을 때 공격자가 계속 접근할 수 있습니다.
기존에는 리프레시 토큰을 발급하면 만료될 때까지 계속 사용했다면, 이제는 매번 사용 후 새로운 토큰으로 교체하여 "one-time use" 특성을 부여합니다. Refresh Token Rotation의 핵심 특징은 세 가지입니다.
첫째, 리프레시 토큰 사용 시 새로운 액세스 토큰과 함께 새로운 리프레시 토큰을 반환합니다. 둘째, 이전 리프레시 토큰은 즉시 무효화되어 다시 사용할 수 없습니다.
셋째, 이미 무효화된 토큰이 사용되면 탈취로 간주하고 모든 토큰을 폐기합니다. 이러한 특징들이 중요한 이유는 토큰 재사용을 탐지하는 순간 공격을 인지하고 즉시 대응할 수 있기 때문입니다.
코드 예제
// Refresh Token Rotation 구현
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
// 주석: 토큰 저장소 (실제로는 Redis나 DB 사용)
const tokenStore = new Map();
// 주석: 새로운 리프레시 토큰 생성
function generateRefreshToken(userId) {
const token = crypto.randomBytes(64).toString('hex');
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7일
// 주석: 토큰 저장 (userId, token, 만료 시간, 사용 여부)
tokenStore.set(token, {
userId,
expiresAt,
used: false,
tokenFamily: crypto.randomUUID() // 주석: 토큰 계열 추적
});
return token;
}
// 주석: 액세스 토큰 생성
function generateAccessToken(userId) {
return jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: '15m' });
}
// 주석: 토큰 갱신 엔드포인트
export async function refreshTokens(req, res) {
const oldRefreshToken = req.cookies.refresh_token;
// 주석: 토큰 존재 여부 확인
const tokenData = tokenStore.get(oldRefreshToken);
if (!tokenData) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// 주석: 재사용 탐지 - 이미 사용된 토큰인 경우
if (tokenData.used) {
// 주석: 같은 계열의 모든 토큰 무효화 (보안 위반 대응)
revokeTokenFamily(tokenData.tokenFamily);
return res.status(403).json({ error: 'Token reuse detected. All sessions revoked.' });
}
// 주석: 만료 확인
if (Date.now() > tokenData.expiresAt) {
tokenStore.delete(oldRefreshToken);
return res.status(401).json({ error: 'Refresh token expired' });
}
// 주석: 이전 토큰 무효화
tokenData.used = true;
// 주석: 새로운 토큰 쌍 생성
const newAccessToken = generateAccessToken(tokenData.userId);
const newRefreshToken = generateRefreshToken(tokenData.userId);
// 주석: 토큰 계열 유지 (재사용 탐지용)
tokenStore.get(newRefreshToken).tokenFamily = tokenData.tokenFamily;
// 주석: 새로운 토큰을 쿠키에 저장
setTokenCookie(res, newAccessToken, newRefreshToken);
res.json({ success: true });
}
// 주석: 토큰 계열 전체 무효화
function revokeTokenFamily(familyId) {
for (const [token, data] of tokenStore.entries()) {
if (data.tokenFamily === familyId) {
tokenStore.delete(token);
}
}
}
설명
이것이 하는 일: Refresh Token Rotation은 리프레시 토큰을 일회용으로 만들어 토큰 탈취를 조기에 탐지하고, 의심스러운 활동 발견 시 모든 세션을 자동으로 종료합니다. 첫 번째로, 사용자가 처음 로그인할 때 서버는 액세스 토큰과 함께 암호학적으로 안전한 랜덤 값으로 생성된 리프레시 토큰을 발급합니다.
이 리프레시 토큰은 데이터베이스나 Redis에 저장되며, userId, 만료 시간, 사용 여부(used 플래그), 그리고 토큰 계열(token family) 정보를 포함합니다. 토큰 계열은 같은 로그인 세션에서 파생된 모든 리프레시 토큰을 추적하기 위한 고유 식별자입니다.
왜 이렇게 하는지는 나중에 재사용 탐지 시 관련된 모든 토큰을 한 번에 무효화하기 위함입니다. 그 다음으로, 액세스 토큰이 만료되어 클라이언트가 리프레시 토큰으로 갱신을 요청하면, 서버는 먼저 해당 토큰이 저장소에 존재하고 유효한지 확인합니다.
그리고 가장 중요한 단계로, used 플래그를 체크합니다. 만약 이 플래그가 이미 true라면 누군가(정당한 사용자 또는 공격자)가 이 토큰을 이미 사용했다는 의미이므로, 토큰 탈취가 발생했을 가능성이 높습니다.
내부에서는 이 경우 즉시 revokeTokenFamily() 함수를 호출하여 같은 토큰 계열에 속한 모든 리프레시 토큰을 데이터베이스에서 삭제합니다. 이렇게 하면 공격자와 정당한 사용자 모두의 세션이 종료되지만, 정당한 사용자는 다시 로그인하면 되고 공격자는 더 이상 접근할 수 없게 됩니다.
마지막으로, 토큰이 유효하고 아직 사용되지 않았다면 서버는 used 플래그를 true로 설정하여 이 토큰을 무효화하고, 새로운 액세스 토큰과 새로운 리프레시 토큰을 생성합니다. 새로운 리프레시 토큰은 이전 토큰과 같은 토큰 계열을 공유하지만 완전히 다른 랜덤 값을 가집니다.
이 새로운 토큰 쌍을 클라이언트에게 반환하고, 클라이언트는 다음 갱신 시 이 새로운 리프레시 토큰을 사용합니다. 최종적으로 각 리프레시 토큰이 정확히 한 번만 사용되므로, 두 번째 사용 시도는 즉시 탐지됩니다.
여러분이 이 코드를 사용하면 리프레시 토큰 탈취를 몇 초 내에 탐지할 수 있습니다. 공격자가 탈취한 토큰을 사용하거나 정당한 사용자가 토큰을 사용하는 순간 재사용이 탐지되고, 모든 관련 세션이 자동으로 종료되어 공격자의 접근이 차단되며, OAuth 2.0 Security Best Current Practice(RFC 8252)가 권장하는 보안 수준을 달성할 수 있습니다.
실전 팁
💡 토큰 저장소는 반드시 Redis나 데이터베이스를 사용하세요. 메모리(Map, Object)에 저장하면 서버 재시작 시 모든 토큰이 사라지고, 멀티 서버 환경에서 동기화 문제가 발생합니다.
💡 토큰 계열(token family) 개념을 구현하여 재사용 탐지 시 관련된 모든 토큰을 무효화하세요. 그렇지 않으면 공격자가 여러 개의 리프레시 토큰을 탈취했을 때 하나씩 계속 사용할 수 있습니다.
💡 재사용 탐지 시 사용자에게 이메일이나 푸시 알림으로 경고하는 것이 좋습니다. "의심스러운 활동이 감지되어 모든 세션이 로그아웃되었습니다"라는 메시지를 보내면 사용자가 즉시 비밀번호를 변경할 수 있습니다.
💡 Grace period(유예 기간)를 구현하여 네트워크 문제로 인한 중복 요청을 허용할 수 있습니다. 예를 들어, 토큰을 무효화한 후 5초 동안은 재사용을 허용하되, 그 이후에는 엄격하게 차단하는 방식입니다.
💡 Auth0, AWS Cognito 같은 인증 서비스를 사용하면 Refresh Token Rotation이 기본 제공됩니다. 직접 구현하기 부담스럽다면 이런 서비스를 활용하는 것도 좋은 선택입니다.
5. Redirect URI 검증
시작하며
여러분이 OAuth 인증을 구현할 때 이런 상황을 겪어본 적 있나요? 공격자가 redirect_uri 파라미터를 조작하여 인증 코드를 자신의 서버로 전송받는 상황입니다.
이것은 "Open Redirect" 취약점으로, OAuth에서 가장 흔하게 발생하는 보안 문제 중 하나입니다. 이런 문제는 OAuth 클라이언트가 redirect_uri를 제대로 검증하지 않을 때 발생합니다.
정상적인 플로우에서는 인증 코드가 여러분의 앱으로 전송되어야 하는데, 공격자가 redirect_uri를 https://attacker.com/callback으로 변조하면 인증 코드가 공격자의 서버로 전송됩니다. 공격자는 이 코드를 사용하여 액세스 토큰을 발급받고 사용자 계정에 접근할 수 있습니다.
바로 이럴 때 필요한 것이 엄격한 Redirect URI 검증입니다. 미리 등록된 정확한 URI만 허용하고, 와일드카드나 부분 매칭을 피하며, 프로토콜과 포트까지 정확히 일치하는지 확인해야 합니다.
개요
간단히 말해서, Redirect URI 검증은 OAuth 콜백이 오직 사전에 등록된 신뢰할 수 있는 URI로만 전송되도록 보장하여, 인증 코드 탈취를 방지하는 보안 메커니즘입니다. OAuth 플로우에서 redirect_uri는 인증이 완료된 후 사용자를 돌려보낼 주소를 지정합니다.
이 주소로 인증 코드가 전달되므로, redirect_uri를 제어할 수 있다면 인증 코드를 가로챌 수 있습니다. 많은 개발자들이 편의를 위해 느슨한 검증을 적용하지만, 이는 심각한 보안 위험을 초래합니다.
예를 들어, https://example.com/*처럼 와일드카드를 허용하거나, startsWith() 같은 부분 매칭을 사용하면 공격자가 https://example.com.attacker.com이나 https://example.com@attacker.com 같은 변종 URL을 사용할 수 있습니다. 기존에는 개발 편의를 위해 여러 redirect_uri를 느슨하게 허용했다면, 이제는 정확한 문자열 매칭(exact match)과 화이트리스트 방식을 사용하여 보안을 강화해야 합니다.
Redirect URI 검증의 핵심 특징은 네 가지입니다. 첫째, OAuth 제공자에 redirect_uri를 사전 등록하고 정확한 매칭만 허용합니다.
둘째, 프로토콜(https), 호스트명, 포트, 경로 모두를 검증합니다. 셋째, 쿼리 파라미터나 프래그먼트는 허용하되 조심스럽게 다룹니다.
넷째, 개발 환경과 프로덕션 환경을 별도로 관리합니다. 이러한 특징들이 중요한 이유는 단 하나의 검증 허점도 전체 OAuth 시스템을 무력화할 수 있기 때문입니다.
코드 예제
// 엄격한 Redirect URI 검증 구현
const allowedRedirectUris = new Set([
'https://yourapp.com/auth/callback',
'https://yourapp.com/auth/silent-callback',
'http://localhost:3000/auth/callback', // 주석: 개발 환경 전용
]);
// 주석: Redirect URI 검증 함수 - 정확한 매칭만 허용
function validateRedirectUri(requestedUri) {
// 주석: URL 파싱 (잘못된 URL은 에러 발생)
let parsedUri;
try {
parsedUri = new URL(requestedUri);
} catch (error) {
return { valid: false, error: 'Invalid URL format' };
}
// 주석: 프로토콜 검증 - http는 localhost만 허용
if (parsedUri.protocol !== 'https:' &&
!(parsedUri.protocol === 'http:' && parsedUri.hostname === 'localhost')) {
return { valid: false, error: 'Invalid protocol' };
}
// 주석: 화이트리스트와 정확히 일치하는지 확인
const uriWithoutFragment = `${parsedUri.origin}${parsedUri.pathname}`;
if (!allowedRedirectUris.has(uriWithoutFragment)) {
return { valid: false, error: 'Redirect URI not registered' };
}
return { valid: true };
}
// 주석: OAuth 인증 엔드포인트
export function authorize(req, res) {
const { redirect_uri, client_id, state } = req.query;
// 주석: Redirect URI 검증
const validation = validateRedirectUri(redirect_uri);
if (!validation.valid) {
// 주석: 검증 실패 시 사용자에게 에러 페이지 표시 (리다이렉트 하지 않음!)
return res.status(400).render('error', {
message: 'Invalid redirect_uri parameter'
});
}
// 주석: 클라이언트 ID 검증 (DB에서 확인)
const client = getClientById(client_id);
if (!client || !client.redirectUris.includes(redirect_uri)) {
return res.status(400).render('error', {
message: 'Client configuration mismatch'
});
}
// 주석: 검증 통과 - 인증 플로우 진행
res.render('authorize', { client, redirect_uri, state });
}
// 주석: 인증 코드 발급 시 redirect_uri 재검증
export function issueAuthCode(req, res) {
const { redirect_uri } = req.body;
const validation = validateRedirectUri(redirect_uri);
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
const authCode = generateAuthCode(req.user.id);
// 주석: 인증 코드와 redirect_uri를 연결하여 저장 (토큰 교환 시 재검증용)
storeAuthCode(authCode, { userId: req.user.id, redirectUri: redirect_uri });
// 주석: 검증된 URI로만 리다이렉트
res.redirect(`${redirect_uri}?code=${authCode}&state=${req.body.state}`);
}
설명
이것이 하는 일: Redirect URI 검증은 인증 코드가 공격자의 서버로 흘러가지 않도록 화이트리스트 기반의 엄격한 검증을 수행합니다. 첫 번째로, OAuth 클라이언트를 등록할 때 허용할 redirect_uri 목록을 명시적으로 지정합니다.
이 목록은 Set이나 배열로 관리되며, 프로토콜, 호스트명, 포트, 경로까지 포함한 완전한 URI를 저장합니다. 예를 들어, https://yourapp.com/auth/callback과 https://yourapp.com/auth/silent-callback은 서로 다른 별도의 URI로 등록됩니다.
왜 이렇게 하는지는 명확합니다. 부분 매칭이나 정규식을 사용하면 공격자가 교묘한 변종 URL로 우회할 수 있기 때문입니다.
프로토콜은 기본적으로 HTTPS만 허용하되, localhost는 개발 편의를 위해 HTTP를 예외적으로 허용합니다. 그 다음으로, 클라이언트가 인증을 요청할 때 제공한 redirect_uri를 URL 객체로 파싱하여 구조를 분석합니다.
파싱에 실패하면(잘못된 URL 형식) 즉시 거부합니다. 파싱이 성공하면 프로토콜이 https인지 확인하고, 호스트명이 의심스러운 패턴(예: 서브도메인에 @나 다른 도메인이 포함된 경우)을 포함하지 않는지 검사합니다.
내부에서는 origin(프로토콜 + 호스트 + 포트)과 pathname을 결합하여 정규화된 URI를 만들고, 이를 화이트리스트와 정확히 비교합니다. 쿼리 파라미터나 프래그먼트(#)는 비교에서 제외되는데, 이들은 클라이언트 측에서 추가 정보를 전달하는 용도로 사용될 수 있기 때문입니다.
마지막으로, 검증이 통과되면 인증 코드를 생성하고 이를 redirect_uri와 함께 저장합니다. 나중에 클라이언트가 이 인증 코드로 토큰을 요청할 때, 다시 한 번 redirect_uri를 검증하여 인증 코드 발급 시 사용된 URI와 정확히 일치하는지 확인합니다.
이 "이중 검증"이 중요한 이유는 공격자가 인증 코드만 탈취한 경우에도 다른 redirect_uri로는 토큰을 받을 수 없도록 막기 위함입니다. 최종적으로 검증된 URI로만 리다이렉트를 수행하여 인증 코드가 안전하게 전달됩니다.
여러분이 이 코드를 사용하면 Open Redirect 취약점을 완전히 차단할 수 있습니다. 공격자가 redirect_uri를 조작하려는 모든 시도가 검증 단계에서 걸러지고, 인증 코드가 사전 등록된 신뢰할 수 있는 서버로만 전송되며, OWASP OAuth 보안 가이드라인을 준수하여 보안 감사를 통과할 수 있습니다.
또한 클라이언트 ID와 redirect_uri를 함께 검증하여 다른 클라이언트의 URI를 도용하는 공격도 방지합니다.
실전 팁
💡 절대로 startsWith(), 정규식, 와일드카드로 redirect_uri를 검증하지 마세요. 정확한 문자열 매칭(exact match)만 안전합니다. https://yourapp.com을 허용하려고 startsWith('https://yourapp.com')을 사용하면 https://yourapp.com.attacker.com도 통과됩니다.
💡 쿼리 파라미터를 포함한 redirect_uri를 등록하는 것은 피하세요. 예를 들어, https://yourapp.com/callback?param=value를 등록하면 관리가 복잡해지고 파라미터 순서 문제가 발생할 수 있습니다. 대신 기본 경로만 등록하고 state 파라미터로 추가 정보를 전달하세요.
💡 개발 환경의 localhost URI는 프로덕션 환경에서 자동으로 비활성화되도록 환경 변수로 관리하세요. 실수로 프로덕션에 localhost URI가 남아있으면 로컬 포트 포워딩 공격에 노출될 수 있습니다.
💡 모바일 앱의 경우 커스텀 URL 스킴(myapp://callback)이나 Universal Links/App Links를 사용하되, 커스텀 스킴은 다른 앱이 등록할 수 있으므로 Universal Links가 더 안전합니다.
💡 OAuth 제공자의 대시보드에서 redirect_uri를 등록할 때도 같은 원칙을 적용하세요. Google, GitHub 등의 OAuth 제공자는 엄격한 매칭을 요구하므로, 정확한 URI를 등록하지 않으면 인증이 실패합니다.
6. Scope 최소 권한 원칙
시작하며
여러분이 OAuth로 사용자 인증을 구현할 때 이런 상황을 겪어본 적 있나요? 단순히 사용자 이름만 필요한데, 실수로 이메일, 연락처, 파일 접근 권한까지 요청하여 사용자가 의심스러워하며 로그인을 거부하는 상황입니다.
과도한 권한 요청은 사용자 신뢰를 떨어뜨리고 보안 위험을 증가시킵니다. 이런 문제는 OAuth scope를 "일단 많이 요청하고 나중에 필요하면 사용하자"는 식으로 접근할 때 발생합니다.
개발자들은 나중에 다시 권한을 요청하는 것이 번거롭다고 생각하여 처음부터 모든 권한을 요청하는 경향이 있습니다. 하지만 이는 정보 보안의 기본 원칙인 "최소 권한의 원칙(Principle of Least Privilege)"을 위반하며, 해킹 시 피해 범위가 훨씬 커집니다.
바로 이럴 때 필요한 것이 Scope 최소 권한 원칙입니다. 현재 기능에 반드시 필요한 최소한의 권한만 요청하고, 추가 기능이 필요할 때 증분 권한 요청(incremental authorization)을 사용합니다.
개요
간단히 말해서, Scope 최소 권한 원칙은 OAuth 인증 시 현재 기능에 꼭 필요한 최소한의 권한만 요청하여, 보안 위험과 사용자 우려를 최소화하는 설계 원칙입니다. OAuth scope는 액세스 토큰이 접근할 수 있는 리소스와 작업을 정의합니다.
예를 들어, read:user는 사용자 프로필 읽기, write:repo는 Git 저장소 쓰기 권한을 의미합니다. Scope가 넓을수록 토큰이 탈취되었을 때 공격자가 할 수 있는 일이 많아집니다.
만약 admin:all 같은 강력한 권한을 가진 토큰이 XSS 공격으로 노출되면, 공격자는 사용자의 모든 데이터를 삭제하거나 설정을 변경할 수 있습니다. 기존에는 편의를 위해 user, repo, admin_org 등 많은 scope를 한 번에 요청했다면, 이제는 로그인에는 read:user만, 저장소 생성 기능에는 추가로 write:repo를 요청하는 식으로 단계적으로 권한을 확장합니다.
Scope 최소 권한 원칙의 핵심 특징은 세 가지입니다. 첫째, 초기 로그인 시 기본적인 프로필 정보만 요청합니다.
둘째, 새로운 기능을 사용할 때 추가 권한을 동적으로 요청합니다. 셋째, 읽기 권한과 쓰기 권한을 분리하여 관리합니다.
이러한 특징들이 중요한 이유는 토큰 탈취 시 피해를 제한하고, 사용자에게 투명성을 제공하며, 규정 준수(GDPR, CCPA)를 용이하게 하기 때문입니다.
코드 예제
// Scope 최소 권한 원칙 적용
// 주석: 기능별로 필요한 최소 scope 정의
const scopeRequirements = {
login: ['read:user'], // 주석: 기본 로그인 - 이름, 이메일만
viewProfile: ['read:user'], // 주석: 프로필 보기
editProfile: ['read:user', 'write:user'], // 주석: 프로필 수정
listRepos: ['read:repo'], // 주석: 저장소 목록
createRepo: ['write:repo'], // 주석: 저장소 생성
deleteData: ['delete:user'] // 주석: 데이터 삭제 - 위험한 작업
};
// 주석: 필요한 scope만 요청하는 함수
function buildAuthUrl(feature) {
const scopes = scopeRequirements[feature] || [];
const scopeParam = scopes.join(' '); // 주석: 공백으로 구분
return `https://oauth.example.com/authorize?` +
`client_id=YOUR_CLIENT_ID&` +
`redirect_uri=https://yourapp.com/callback&` +
`response_type=code&` +
`scope=${encodeURIComponent(scopeParam)}`;
}
// 주석: 증분 권한 요청 - 추가 scope 동적 요청
export async function requestAdditionalScopes(currentToken, additionalScopes) {
// 주석: 현재 토큰의 scope 확인
const currentScopes = decodeToken(currentToken).scope.split(' ');
const missingScopes = additionalScopes.filter(s => !currentScopes.includes(s));
// 주석: 이미 권한이 있으면 재요청 불필요
if (missingScopes.length === 0) {
return { success: true, token: currentToken };
}
// 주석: 추가 권한 요청 URL 생성
const authUrl = `https://oauth.example.com/authorize?` +
`client_id=YOUR_CLIENT_ID&` +
`redirect_uri=https://yourapp.com/callback&` +
`response_type=code&` +
`scope=${encodeURIComponent(missingScopes.join(' '))}&` +
`prompt=consent`; // 주석: 사용자에게 새로운 권한 동의 요청
return { success: false, authUrl, message: 'Additional permissions required' };
}
// 주석: API 호출 전 scope 검증
function checkScope(token, requiredScope) {
const tokenScopes = decodeToken(token).scope.split(' ');
if (!tokenScopes.includes(requiredScope)) {
throw new Error(`Insufficient scope. Required: ${requiredScope}`);
}
}
// 주석: 보호된 엔드포인트 예시
export async function deleteUserData(req, res) {
const token = req.cookies.access_token;
// 주석: 위험한 작업은 반드시 명시적 scope 검증
checkScope(token, 'delete:user');
await performDeleteOperation(getUserId(token));
res.json({ success: true });
}
설명
이것이 하는 일: Scope 최소 권한 원칙은 OAuth 권한을 기능별로 세분화하고, 필요한 시점에만 요청하여 보안과 사용자 경험을 동시에 향상시킵니다. 첫 번째로, 애플리케이션의 각 기능이 필요로 하는 최소 scope를 명확히 정의합니다.
예를 들어, 사용자가 처음 로그인할 때는 read:user만 요청하여 이름과 이메일 정보만 가져옵니다. 이 단계에서는 저장소 접근이나 데이터 수정 권한을 요청하지 않습니다.
왜 이렇게 하는지는 간단합니다. 사용자는 "왜 이 앱이 내 저장소를 수정하려고 하지?"라고 의심하지 않고 편안하게 로그인할 수 있습니다.
각 scope는 명확한 명명 규칙을 따르며, 일반적으로 동작:리소스 형식(예: read:user, write:repo)을 사용합니다. 그 다음으로, 사용자가 새로운 기능을 사용하려고 할 때 증분 권한 요청(incremental authorization)을 수행합니다.
예를 들어, 사용자가 "새 저장소 만들기" 버튼을 클릭하면 현재 토큰의 scope를 확인하고, write:repo 권한이 없으면 추가 권한 요청 플로우를 시작합니다. 내부에서는 JWT를 디코딩하여 scope 클레임을 읽고, 필요한 scope 목록과 비교합니다.
누락된 scope가 있으면 사용자를 OAuth 제공자로 리다이렉트하되, prompt=consent 파라미터를 포함시켜 "이 앱이 추가로 X 권한을 요청합니다" 라는 동의 화면을 표시합니다. 사용자가 동의하면 새로운 scope를 포함한 토큰이 발급됩니다.
마지막으로, API 엔드포인트에서 실제로 작업을 수행하기 전에 토큰의 scope를 검증합니다. 특히 데이터 삭제, 설정 변경 같은 위험한 작업은 반드시 명시적인 scope 검증을 거쳐야 합니다.
예를 들어, deleteUserData 함수는 실행 전에 토큰이 delete:user scope를 가지고 있는지 확인하고, 없으면 에러를 반환합니다. 최종적으로 각 토큰은 발급 시점에 사용자가 동의한 scope만 가지며, 그 이상의 작업을 시도하면 거부됩니다.
여러분이 이 코드를 사용하면 토큰 탈취 시 피해 범위를 대폭 줄일 수 있습니다. 기본 로그인 토큰이 탈취되어도 공격자는 프로필 읽기만 가능하고 데이터를 수정하거나 삭제할 수 없으며, 사용자는 각 권한이 왜 필요한지 명확히 알 수 있어 신뢰도가 높아지고, GDPR의 "목적 제한" 원칙을 준수하여 개인정보 보호 규정을 만족시킬 수 있습니다.
실전 팁
💡 scope 이름을 명확하고 직관적으로 지으세요. read:user.email은 이메일만 읽는다는 의미가 명확하지만, user_data는 모호합니다. OAuth 제공자의 scope 문서를 참고하여 표준 명명 규칙을 따르세요.
💡 읽기와 쓰기 권한을 항상 분리하세요. user보다는 read:user와 write:user로 나누어 관리하면, 대부분의 기능은 읽기 권한만으로 작동하고 수정이 필요한 순간에만 쓰기 권한을 요청할 수 있습니다.
💡 민감한 작업(계정 삭제, 결제, 관리자 기능)은 별도의 고위험 scope로 분리하고, 사용 시마다 재인증을 요구하는 것도 좋은 방법입니다. 예를 들어, dangerous:delete scope는 발급 후 5분만 유효하도록 설정할 수 있습니다.
💡 JWT의 scope 클레임을 검증하는 미들웨어를 만들어 모든 보호된 엔드포인트에 적용하세요. Express의 경우 requireScope('write:repo') 같은 미들웨어를 만들면 코드 중복을 줄이고 일관성을 유지할 수 있습니다.
💡 사용자에게 현재 부여된 권한 목록을 확인하고 철회할 수 있는 설정 페이지를 제공하세요. "앱 권한 관리" 페이지에서 사용자가 직접 scope를 제거할 수 있으면 투명성과 신뢰가 크게 향상됩니다.
7. HTTPS 강제 사용
시작하며
여러분이 OAuth 인증을 HTTP로 구현했을 때 이런 상황을 겪어본 적 있나요? 카페나 공항의 공용 Wi-Fi에서 사용자가 로그인했는데, 중간자 공격(Man-in-the-Middle Attack)으로 인증 코드와 토큰이 그대로 노출되는 상황입니다.
HTTP는 암호화되지 않으므로 네트워크 패킷을 감청하면 모든 데이터를 볼 수 있습니다. 이런 문제는 개발 편의를 위해 HTTP를 사용하거나, HTTPS 설정이 복잡해서 미루다가 프로덕션에도 HTTP가 남아있을 때 발생합니다.
OAuth 플로우는 여러 리다이렉트를 거치면서 URL에 인증 코드를 포함시키므로, 단 한 번의 HTTP 요청만으로도 공격자가 전체 인증을 가로챌 수 있습니다. 특히 악의적인 공용 Wi-Fi AP(Access Point)나 ISP 레벨의 감청에 취약합니다.
바로 이럴 때 필요한 것이 HTTPS 강제 사용입니다. 모든 OAuth 관련 통신을 TLS/SSL로 암호화하고, HTTP 요청을 자동으로 HTTPS로 리다이렉트하며, HSTS(HTTP Strict Transport Security) 헤더로 브라우저가 HTTP를 아예 시도하지 못하도록 차단합니다.
개요
간단히 말해서, HTTPS 강제 사용은 OAuth 플로우의 모든 단계에서 TLS/SSL 암호화를 적용하여 중간자 공격, 패킷 감청, 세션 하이재킹을 방지하는 필수 보안 조치입니다. HTTP와 HTTPS의 차이는 단순히 속도나 SEO 문제가 아닙니다.
HTTP는 평문(plaintext)으로 통신하므로 같은 네트워크에 있는 누구나 Wireshark 같은 도구로 패킷을 캡처하여 내용을 읽을 수 있습니다. OAuth에서는 URL 쿼리 파라미터에 인증 코드(?code=...)가 포함되고, POST 요청 바디에 토큰이 포함되므로, HTTP를 사용하면 이 모든 정보가 노출됩니다.
예를 들어, http://yourapp.com/callback?code=ABC123이라는 URL은 라우터 로그, 프록시 서버, ISP 기록에 그대로 남습니다. 기존에는 "나중에 HTTPS를 추가하면 되지"라고 생각했다면, 이제는 개발 초기부터 HTTPS를 기본으로 설정하고 HTTP를 완전히 차단하는 것이 표준입니다.
HTTPS 강제 사용의 핵심 특징은 네 가지입니다. 첫째, 모든 OAuth 엔드포인트가 HTTPS로만 접근 가능하도록 설정합니다.
둘째, HTTP 요청을 301 리다이렉트로 HTTPS로 전환합니다. 셋째, HSTS 헤더를 설정하여 브라우저가 향후 자동으로 HTTPS를 사용하도록 강제합니다.
넷째, Mixed Content(HTTPS 페이지에서 HTTP 리소스 로드)를 차단합니다. 이러한 특징들이 중요한 이유는 단 하나의 HTTP 요청도 전체 보안을 무너뜨릴 수 있기 때문입니다.
코드 예제
// HTTPS 강제 및 HSTS 헤더 설정
import express from 'express';
import helmet from 'helmet';
const app = express();
// 주석: Helmet으로 기본 보안 헤더 설정
app.use(helmet());
// 주석: HTTPS 강제 리다이렉트 미들웨어
app.use((req, res, next) => {
// 주석: 프로덕션 환경에서만 적용
if (process.env.NODE_ENV === 'production') {
// 주석: X-Forwarded-Proto 헤더 확인 (리버스 프록시/로드 밸런서 사용 시)
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
if (protocol !== 'https') {
// 주석: 301 영구 리다이렉트로 HTTPS로 전환
return res.redirect(301, `https://${req.headers.host}${req.url}`);
}
}
next();
});
// 주석: HSTS (HTTP Strict Transport Security) 헤더 설정
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'production') {
// 주석: 1년간 HTTPS만 사용, 서브도메인 포함, preload 목록 등록
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
}
next();
});
// 주석: CSP로 Mixed Content 차단
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // 주석: 프로덕션에서는 nonce 사용 권장
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'https:', 'data:'],
upgradeInsecureRequests: [], // 주석: HTTP 리소스를 자동으로 HTTPS로 업그레이드
}
})
);
// 주석: OAuth redirect_uri 검증 - HTTPS만 허용
function validateRedirectUri(uri) {
const parsed = new URL(uri);
// 주석: localhost 외에는 HTTPS 강제
if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost') {
throw new Error('Redirect URI must use HTTPS');
}
return true;
}
// 주석: OAuth 클라이언트 설정 예시
const oauthConfig = {
authorizationUrl: 'https://oauth.example.com/authorize', // 주석: 반드시 HTTPS
tokenUrl: 'https://oauth.example.com/token', // 주석: 반드시 HTTPS
redirectUri: 'https://yourapp.com/auth/callback', // 주석: 반드시 HTTPS
};
app.listen(process.env.PORT || 3000);
설명
이것이 하는 일: HTTPS 강제 사용은 OAuth 플로우의 전 구간을 TLS로 암호화하고, 여러 계층의 보안 헤더로 HTTP 사용을 원천 차단합니다. 첫 번째로, 서버에 TLS 인증서를 설치하고 HTTPS를 활성화합니다.
Let's Encrypt를 사용하면 무료로 자동 갱신되는 인증서를 받을 수 있으며, Cloudflare나 AWS Certificate Manager 같은 서비스를 사용하면 더 쉽게 설정할 수 있습니다. 서버가 HTTPS를 지원하면 모든 통신이 TLS 1.2 이상으로 암호화되어, 공격자가 패킷을 캡처하더라도 암호화된 데이터만 보입니다.
왜 이렇게 하는지는 명확합니다. TLS는 대칭키와 비대칭키 암호화를 조합하여 전송 중인 데이터를 보호하고, 인증서로 서버의 신원도 검증합니다.
Express 앱 앞에 Nginx나 리버스 프록시를 두는 경우, 프록시에서 TLS를 종료(terminate)하고 X-Forwarded-Proto 헤더를 설정하여 백엔드에 원래 프로토콜을 알려줍니다. 그 다음으로, HTTP 요청이 들어오면 자동으로 HTTPS로 리다이렉트하는 미들웨어를 추가합니다.
301 Permanent Redirect를 사용하면 브라우저와 검색 엔진이 "이 URL은 영구적으로 HTTPS로 이동했다"고 인식하여 다음부터는 자동으로 HTTPS를 사용합니다. 내부에서는 req.protocol이나 X-Forwarded-Proto 헤더를 확인하여 현재 연결이 HTTPS인지 판단하고, 아니면 같은 호스트와 경로로 HTTPS URL을 만들어 리다이렉트합니다.
하지만 리다이렉트만으로는 충분하지 않습니다. 최초 HTTP 요청은 여전히 평문으로 전송되므로, 그 순간 공격자가 개입할 수 있습니다.
마지막으로, HSTS(HTTP Strict Transport Security) 헤더를 설정하여 브라우저가 HTTP를 아예 시도하지 못하도록 차단합니다. HSTS 헤더를 받은 브라우저는 지정된 기간(예: 1년) 동안 해당 도메인에 대한 모든 요청을 자동으로 HTTPS로 변환합니다.
사용자가 http://yourapp.com을 입력하더라도 브라우저가 서버에 요청을 보내기 전에 클라이언트 측에서 https://yourapp.com으로 바꿉니다. includeSubDomains 옵션은 모든 서브도메인에도 HSTS를 적용하고, preload 옵션은 브라우저의 내장 목록에 등록하여 최초 방문부터 보호받을 수 있게 합니다.
최종적으로 CSP의 upgradeInsecureRequests 지시어로 페이지 내의 HTTP 리소스(이미지, 스크립트 등)도 자동으로 HTTPS로 업그레이드하여 Mixed Content 경고를 방지합니다. 여러분이 이 코드를 사용하면 중간자 공격으로부터 완벽하게 보호받을 수 있습니다.
공용 Wi-Fi에서도 인증 코드와 토큰이 암호화되어 전송되고, HSTS로 첫 방문부터 HTTPS를 강제하여 공격 시간 창을 없애며, OAuth 2.0 Threat Model(RFC 6819)이 요구하는 전송 계층 보안을 완벽히 만족시킬 수 있습니다.
실전 팁
💡 Let's Encrypt와 Certbot을 사용하면 무료로 3개월마다 자동 갱신되는 인증서를 받을 수 있습니다. certbot --nginx 명령어 하나로 Nginx 설정까지 자동으로 완료됩니다.
💡 HSTS preload 목록에 등록하려면 hstspreload.org에 도메인을 제출하세요. 등록되면 Chrome, Firefox, Safari 등 주요 브라우저가 처음부터 HTTPS만 사용합니다. 단, 한 번 등록하면 제거가 어려우므로 신중히 결정하세요.
💡 개발 환경에서는 mkcert 같은 도구로 로컬 HTTPS 인증서를 만들어 localhost에서도 HTTPS를 사용하세요. 이렇게 하면 개발 환경과 프로덕션 환경의 차이를 줄이고, HTTPS 관련 버그를 조기에 발견할 수 있습니다.
💡 Cloudflare를 사용하면 무료로 HTTPS를 활성화하고 자동으로 인증서를 관리할 수 있습니다. Full(Strict) 모드를 사용하여 Cloudflare와 오리진 서버 사이도 암호화하세요.
💡 HTTP/2를 함께 활성화하면 HTTPS의 오버헤드를 줄이고 성능을 개선할 수 있습니다. 대부분의 최신 웹 서버(Nginx, Apache, Node.js)는 HTTP/2를 지원하며, HTTPS가 전제 조건입니다.
8. 토큰 만료 시간 설정
시작하며
여러분이 OAuth 토큰을 발급할 때 이런 상황을 겪어본 적 있나요? 편의를 위해 액세스 토큰의 만료 시간을 1년으로 설정했는데, 토큰이 탈취된 후 공격자가 1년 내내 사용자 계정에 접근하는 상황입니다.
토큰 만료 시간이 길수록 보안 위험이 기하급수적으로 증가합니다. 이런 문제는 "토큰을 자주 갱신하면 사용자 경험이 나빠진다"는 오해에서 비롯됩니다.
개발자들은 사용자가 자주 로그아웃되는 것을 걱정하여 토큰 만료 시간을 매우 길게 설정하는 경향이 있습니다. 하지만 리프레시 토큰을 제대로 활용하면 짧은 액세스 토큰으로도 seamless한 사용자 경험을 제공할 수 있으며, 보안도 크게 향상됩니다.
바로 이럴 때 필요한 것이 적절한 토큰 만료 시간 설정입니다. 액세스 토큰은 15분 정도로 짧게, 리프레시 토큰은 수일에서 수개월로 설정하고, 민감한 작업은 추가 인증을 요구하는 다층 방어 전략을 사용합니다.
개요
간단히 말해서, 토큰 만료 시간 설정은 액세스 토큰과 리프레시 토큰의 유효 기간을 적절히 조정하여, 보안과 사용자 편의성 사이의 균형을 맞추는 보안 설계 원칙입니다. 토큰의 만료 시간은 "공격자가 토큰을 탈취했을 때 얼마나 오래 사용할 수 있는가"를 결정하는 가장 직접적인 요소입니다.
액세스 토큰이 1년간 유효하면 공격자는 1년 동안 무제한 접근할 수 있지만, 15분만 유효하면 피해를 15분으로 제한할 수 있습니다. 많은 개발자들이 "짧은 만료 시간 = 자주 로그아웃"이라고 오해하지만, 실제로는 리프레시 토큰이 백그라운드에서 자동으로 새로운 액세스 토큰을 발급받으므로 사용자는 전혀 느끼지 못합니다.
기존에는 액세스 토큰을 수일 또는 수개월간 사용했다면, 이제는 15분~1시간으로 설정하고 리프레시 토큰으로 자동 갱신하는 것이 업계 표준입니다. 토큰 만료 시간 설정의 핵심 특징은 네 가지입니다.
첫째, 액세스 토큰은 짧게(15분1시간) 설정하여 탈취 피해를 최소화합니다. 둘째, 리프레시 토큰은 길게(7일90일) 설정하여 사용자가 자주 로그인하지 않도록 합니다.
셋째, 토큰 타입과 민감도에 따라 차등 적용합니다. 넷째, 절대 만료 시간(absolute expiration)과 슬라이딩 만료 시간(sliding expiration)을 조합합니다.
이러한 특징들이 중요한 이유는 공격 피해를 시간적으로 제한하면서도 정상적인 사용자에게는 불편을 주지 않기 때문입니다.
코드 예제
// 토큰 만료 시간 설정 및 자동 갱신
import jwt from 'jsonwebtoken';
// 주석: 만료 시간 상수 정의
const EXPIRATION = {
ACCESS_TOKEN: 15 * 60, // 주석: 15분 (초 단위)
REFRESH_TOKEN: 7 * 24 * 60 * 60, // 주석: 7일
SENSITIVE_ACTION: 5 * 60, // 주석: 민감한 작업용 - 5분
ABSOLUTE_SESSION: 30 * 24 * 60 * 60 // 주석: 절대 만료 - 30일
};
// 주석: 액세스 토큰 생성 - 짧은 만료 시간
function generateAccessToken(userId, scope) {
return jwt.sign(
{
userId,
scope,
type: 'access'
},
process.env.JWT_SECRET,
{
expiresIn: EXPIRATION.ACCESS_TOKEN, // 주석: 15분 후 만료
issuer: 'yourapp.com',
audience: 'api.yourapp.com'
}
);
}
// 주석: 리프레시 토큰 생성 - 긴 만료 시간
function generateRefreshToken(userId) {
const absoluteExpiry = Date.now() + EXPIRATION.ABSOLUTE_SESSION * 1000;
return jwt.sign(
{
userId,
type: 'refresh',
absoluteExpiry // 주석: 절대 만료 시간 기록
},
process.env.REFRESH_TOKEN_SECRET,
{
expiresIn: EXPIRATION.REFRESH_TOKEN // 주석: 7일 후 만료 (슬라이딩)
}
);
}
// 주석: 자동 토큰 갱신 (클라이언트 측)
class TokenManager {
constructor() {
this.refreshTimer = null;
}
// 주석: 만료 5분 전에 자동 갱신
scheduleRefresh(accessToken) {
const decoded = jwt.decode(accessToken);
const expiresIn = decoded.exp * 1000 - Date.now();
const refreshTime = expiresIn - 5 * 60 * 1000; // 주석: 5분 전
// 주석: 이전 타이머 취소
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
// 주석: 새로운 타이머 설정
this.refreshTimer = setTimeout(async () => {
await this.refreshAccessToken();
}, Math.max(0, refreshTime));
}
async refreshAccessToken() {
// 주석: 리프레시 토큰으로 새 액세스 토큰 요청
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include' // 주석: httpOnly 쿠키 포함
});
if (response.ok) {
const { accessToken } = await response.json();
this.scheduleRefresh(accessToken); // 주석: 다음 갱신 예약
} else {
// 주석: 리프레시 실패 - 재로그인 필요
window.location.href = '/login';
}
}
}
// 주석: 민감한 작업 - 재인증 요구
export async function performSensitiveAction(req, res) {
const { reauth_token } = req.body;
// 주석: 최근 5분 이내에 재인증했는지 확인
if (!reauth_token || !isRecentAuth(reauth_token, EXPIRATION.SENSITIVE_ACTION)) {
return res.status(403).json({
error: 'Re-authentication required',
reauth_url: '/auth/verify-password'
});
}
// 주석: 재인증 확인 후 작업 수행
await deleteSensitiveData(req.user.id);
res.json({ success: true });
}
// 주석: 절대 만료 시간 검증
function isRecentAuth(token, maxAge) {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const age = Date.now() - decoded.iat * 1000;
return age <= maxAge * 1000;
}
설명
이것이 하는 일: 토큰 만료 시간 설정은 다양한 토큰 타입에 적절한 만료 시간을 부여하고, 자동 갱신 메커니즘으로 사용자 경험을 유지하면서 보안을 극대화합니다. 첫 번째로, 액세스 토큰을 생성할 때 expiresIn을 15분(900초)으로 설정합니다.
JWT를 사용하는 경우 exp 클레임에 만료 시간이 Unix 타임스탬프로 포함되며, 이를 검증하는 라이브러리(jsonwebtoken, jose 등)가 자동으로 만료 여부를 확인합니다. 15분은 대부분의 사용자 세션에서 충분한 시간이며, 탈취되더라도 공격자가 사용할 수 있는 시간이 매우 제한적입니다.
왜 이렇게 하는지는 간단합니다. 짧은 만료 시간은 "blast radius(폭발 반경)"를 줄여 피해를 국소화합니다.
리프레시 토큰은 7일로 설정하여 사용자가 일주일에 한 번 정도만 로그인하면 되도록 합니다. 그 다음으로, 클라이언트 측에서 TokenManager 클래스를 구현하여 액세스 토큰의 만료 시간을 추적하고, 만료 5분 전에 자동으로 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.
내부에서는 JWT의 exp 클레임을 디코딩하여(검증 없이 단순 읽기) 만료 시간을 계산하고, setTimeout으로 타이머를 설정합니다. 타이머가 만료되면 /api/auth/refresh 엔드포인트를 호출하여 새로운 액세스 토큰을 받고, 받은 토큰으로 다시 타이머를 예약하는 방식으로 무한히 갱신됩니다.
사용자는 이 과정을 전혀 인지하지 못하며 seamless한 경험을 얻습니다. 마지막으로, 절대 만료 시간(absolute expiration)과 슬라이딩 만료 시간(sliding expiration)을 조합합니다.
슬라이딩 만료는 토큰을 사용할 때마다 만료 시간이 연장되는 방식으로, 리프레시 토큰을 7일마다 갱신하면 사실상 무한정 로그인 상태가 유지됩니다. 하지만 이것만으로는 장기 탈취 위험이 있으므로, 절대 만료 시간을 30일로 설정하여 최초 로그인으로부터 30일이 지나면 무조건 재로그인을 요구합니다.
최종적으로 계정 삭제, 비밀번호 변경 같은 민감한 작업은 5분 이내에 재인증한 경우에만 허용하여 추가 보안 레이어를 제공합니다. 여러분이 이 코드를 사용하면 토큰 탈취 피해를 15분으로 제한할 수 있습니다.
공격자가 액세스 토큰을 훔쳐도 15분 후에는 무용지물이 되고, 리프레시 토큰을 훔치더라도 Rotation으로 즉시 탐지되며, 사용자는 자동 갱신 덕분에 로그아웃을 경험하지 않고, 민감한 작업은 재인증으로 이중 보호됩니다.
실전 팁
💡 만료 시간은 애플리케이션의 성격에 따라 조정하세요. 뱅킹 앱은 액세스 토큰을 5분, 리프레시 토큰을 1일로 설정하지만, 소셜 미디어 앱은 1시간과 30일도 괜찮습니다. 민감도에 따라 달라집니다.
💡 JWT의 exp 클레임 외에 iat(issued at)과 nbf(not before)도 함께 사용하여 토큰의 유효 기간을 정밀하게 제어하세요. 특히 nbf는 미래 날짜로 설정하여 토큰이 특정 시점 이전에는 사용되지 못하도록 할 수 있습니다.
💡 리프레시 토큰 갱신 시 사용자의 활동 패턴을 분석하여 의심스러운 행동(예: IP 주소 급변, 비정상적인 시간대)을 탐지하면 자동으로 세션을 종료하고 알림을 보내세요.
💡 "Remember me" 기능을 구현할 때는 별도의 장기 토큰(30일~1년)을 발급하되, 이 토큰으로는 읽기 작업만 가능하고 민감한 작업은 재로그인을 요구하세요.
💡 서버 시간과 클라이언트 시간이 동기화되지 않을 수 있으므로, 토큰 검증 시 5분 정도의 "clock skew" 여유를 두는 것이 좋습니다. 대부분의 JWT 라이브러리는 이 옵션을 제공합니다.