OAuth 2.1 변경점과 실무 적용 가이드 — PKCE 필수화와 보안 강화
OAuth 2.0→2.1 주요 변경점, Implicit Flow 제거, PKCE 필수화, Refresh Token Rotation, Node.js 구현 패턴, 보안 체크리스트.
한 줄 요약: OAuth 2.1은 Implicit Flow 완전 제거, PKCE 전면 필수화, Refresh Token Rotation 강제를 통해 10년간 누적된 OAuth 2.0 보안 취약점을 정리한 통합 명세다.
- OAuth 2.0 기반 인증을 운영 중이며 2.1 전환을 검토하는 백엔드 개발자
- SPA 또는 모바일 앱에서 Authorization Code + PKCE 흐름을 구현해야 하는 개발자
- 보안 감사에서 OAuth 관련 지적을 받은 팀
- 신규 서비스의 인증 아키텍처를 설계하는 개발자
OAuth 2.0 → 2.1 핵심 변경점 5가지
OAuth 2.1은 새로운 프로토콜이 아니다. IETF의 draft-ietf-oauth-v2-1은 RFC 6749(OAuth 2.0), RFC 6750(Bearer Token), RFC 7636(PKCE), RFC 9126(PAR) 등 여러 RFC를 단일 문서로 통합하고, 10년간 보안 연구를 통해 밝혀진 취약 패턴을 공식적으로 폐기한다.
#access_token=...)에 노출되어 브라우저 히스토리, Referrer 헤더, 서드파티 스크립트에서 탈취될 수 있다. PKCE를 사용한 Authorization Code Flow는 code를 탈취해도 code_verifier 없이는 토큰을 발급받을 수 없다.PKCE 동작 원리와 Node.js 구현
PKCE(Proof Key for Code Exchange, RFC 7636)는 Authorization Code 탈취 공격을 막는 메커니즘이다. 클라이언트가 code_verifier(랜덤 문자열)를 생성하고 그 SHA-256 해시인 code_challenge를 Authorization 요청에 포함시킨다. Token 요청 시 원본 code_verifier를 서버가 검증해 코드 재사용을 차단한다.
PKCE code_verifier / code_challenge 생성 (Node.js)import crypto from 'crypto' // code_verifier: 43~128자 랜덤 문자열 (URL-safe base64) function generateCodeVerifier(): string { return crypto.randomBytes(32).toString('base64url') } // code_challenge: code_verifier의 SHA-256 해시를 base64url 인코딩 function generateCodeChallenge(verifier: string): string { return crypto .createHash('sha256') .update(verifier) .digest('base64url') } const codeVerifier = generateCodeVerifier() const codeChallenge = generateCodeChallenge(codeVerifier) console.log('verifier:', codeVerifier) console.log('challenge:', codeChallenge)
Authorization 요청 URL 생성 (Express)import express from 'express' import crypto from 'crypto' const app = express() app.use(express.json()) // 세션 또는 서버사이드 스토어에 verifier 저장 필요 const store = new Map<string, string>() app.get('/auth/login', (req, res) => { const codeVerifier = crypto.randomBytes(32).toString('base64url') const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url') const state = crypto.randomBytes(16).toString('hex') // state를 키로 verifier 임시 저장 (Redis 등 외부 스토어 권장) store.set(state, codeVerifier) const params = new URLSearchParams({ response_type: 'code', client_id: process.env.CLIENT_ID ?? '', redirect_uri: 'https://yourapp.com/auth/callback', scope: 'openid profile email', state, code_challenge: codeChallenge, code_challenge_method: 'S256', }) res.redirect(`https://auth.example.com/authorize?${params.toString()}`) })
Token 교환 — Callback 핸들러app.get('/auth/callback', async (req, res) => { const { code, state } = req.query as { code: string; state: string } const codeVerifier = store.get(state) if (!codeVerifier) { return res.status(400).json({ error: 'invalid_state' }) } store.delete(state) const tokenRes = await fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: 'https://yourapp.com/auth/callback', client_id: process.env.CLIENT_ID ?? '', code_verifier: codeVerifier, }), }) const tokens = await tokenRes.json() // Access Token을 httpOnly 쿠키로 저장 (localStorage 금지) res.cookie('access_token', tokens.access_token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: tokens.expires_in * 1000, }) res.redirect('/dashboard') })
Refresh Token Rotation — 구현 패턴과 재사용 감지
OAuth 2.1에서 Refresh Token Rotation은 필수다. 토큰을 갱신할 때마다 새 Refresh Token을 발급하고 이전 토큰을 즉시 무효화한다. 만약 이미 무효화된 Refresh Token으로 갱신을 시도하면 — 즉 토큰이 탈취된 정황 — 해당 사용자의 모든 세션을 즉시 무효화해야 한다.
Refresh Token Rotation 서버 구현 (Node.js + 의사코드)// Token 갱신 엔드포인트 app.post('/auth/refresh', async (req, res) => { const { refresh_token } = req.body // DB에서 refresh token 조회 const tokenRecord = await db.refreshTokens.findOne({ token: refresh_token }) if (!tokenRecord) { // 존재하지 않는 토큰 — 이미 사용됐거나 위조된 토큰 // 해당 family의 모든 세션 무효화 (Refresh Token Family 패턴) const userId = await db.refreshTokens.findUserByFamily(refresh_token) if (userId) await db.refreshTokens.revokeAllByUser(userId) return res.status(401).json({ error: 'token_reuse_detected' }) } if (tokenRecord.expiresAt < new Date()) { await db.refreshTokens.delete({ token: refresh_token }) return res.status(401).json({ error: 'token_expired' }) } // Rotation: 기존 토큰 삭제 후 새 토큰 발급 const newRefreshToken = crypto.randomBytes(32).toString('base64url') await db.refreshTokens.delete({ token: refresh_token }) await db.refreshTokens.create({ token: newRefreshToken, userId: tokenRecord.userId, family: tokenRecord.family, // family ID로 재사용 탐지 expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), }) const newAccessToken = issueAccessToken(tokenRecord.userId) res.json({ access_token: newAccessToken, refresh_token: newRefreshToken }) })
보안 체크리스트와 흔한 실수 5가지
OAuth 구현 시 반복적으로 발생하는 취약점 패턴과 그 해결법을 정리한다. 내부 감사나 보안 리뷰 전 체크리스트로 활용할 수 있다.
Redirect URI 완전 일치 검증 예시 (Node.js):
Redirect URI 서버 사이드 검증const ALLOWED_REDIRECT_URIS = new Set([ 'https://yourapp.com/auth/callback', 'https://staging.yourapp.com/auth/callback', ]) function validateRedirectUri(uri: string): boolean { // 완전 일치만 허용 — URL 파싱 후 비교하여 경로 조작 방지 try { const parsed = new URL(uri) const normalized = `${parsed.origin}${parsed.pathname}` return ALLOWED_REDIRECT_URIS.has(normalized) } catch { return false } } // Authorization 요청 처리 시 app.get('/authorize', (req, res) => { const { redirect_uri } = req.query as { redirect_uri: string } if (!validateRedirectUri(redirect_uri)) { return res.status(400).json({ error: 'invalid_redirect_uri' }) } // 이후 처리... })