OAuth2 PKCE Flow: 안전한 인증을 위한 원리, 실전 동작 과정, 코드 예제까지 완벽 정리
OAuth2 PKCE(Proof Key for Code Exchange)는 모바일 앱과 SPA에서 인가코드 플로우의 보안 취약점을 해결하는 OAuth2 확장 프로토콜로, code_verifier와 code_challenge를 통해 인증 보안을 강화하는 2025년 필수 인증 방식입니다.
OAuth2 PKCE란 무엇인가?
PKCE(Proof Key for Code Exchange)는 RFC 7636에서 정의된 OAuth2의 보안 확장 프로토콜입니다.
기존 OAuth2 인가코드 플로우에서 발생할 수 있는 인가코드 탈취 공격을 방지하기 위해 개발되었으며,
특히 모바일 인증과 SPA(Single Page Application) 환경에서 필수적으로 사용되는 OAuth2 보안 강화 방법입니다.
PKCE는 클라이언트가 동적으로 생성한 비밀 값(code_verifier)을 사용하여 인가코드와 액세스 토큰 교환 과정을 보호합니다.
이를 통해 공격자가 인가코드를 탈취하더라도 실제 access token을 획득할 수 없도록 하는 OAuth2 취약점 해결책입니다.
PKCE 핵심 구성 요소
[클라이언트 앱] ←→ [인가 서버] ←→ [리소스 서버]
↓
code_verifier (비밀값)
↓ SHA256
code_challenge (공개값)
- Code Verifier: 클라이언트가 생성하는 43-128자의 랜덤 비밀 문자열
- Code Challenge: Code Verifier를 SHA256으로 해시한 공개 가능한 값
- 인가코드: 임시로 발급되는 일회성 코드 (기존 OAuth2와 동일)
- 검증 과정: 인가 서버가 Code Challenge와 Code Verifier의 일치성을 확인
OAuth2 기본 플로우 vs PKCE 플로우 비교
구분 | 기본 OAuth2 인가코드 플로우 | OAuth2 PKCE 플로우 |
---|---|---|
보안성 | 인가코드 탈취 위험 존재 | code_verifier로 보안 강화 |
적용 환경 | 웹 서버 애플리케이션 | 모바일 앱, SPA |
클라이언트 시크릿 | 필수 | 불필요 |
추가 파라미터 | 없음 | code_challenge, code_challenge_method |
복잡도 | 낮음 | 중간 |
보안 등급 | 중간 | 높음 |
기존 OAuth2 인가코드 플로우는 클라이언트 시크릿에 의존하지만, 모바일 앱에서는 이 시크릿을 안전하게 저장하기 어렵습니다.
PKCE는 이러한 제약을 해결하여 클라이언트 시크릿 없이도 안전한 OAuth2 인증을 가능하게 합니다.
OAuth2 PKCE 동작원리 상세 분석
Code Verifier와 Code Challenge 생성
PKCE의 핵심은 code_verifier와 code_challenge 쌍을 통한 검증 메커니즘입니다.
JavaScript (브라우저) - 핵심 구현
// PKCE 파라미터 생성 함수
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// 실제 사용 예제
async function startOAuth2Login() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// 세션에 저장
sessionStorage.setItem('code_verifier', codeVerifier);
// 인가 URL 생성
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
`response_type=code&` +
`client_id=your-client-id&` +
`redirect_uri=https://yourapp.com/callback&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256&` +
`scope=openid profile email`;
window.location.href = authUrl;
}
PKCE 동작 순서
PKCE Flow 상세 과정
1. [클라이언트] Code Verifier 생성 (랜덤 문자열)
↓
2. [클라이언트] Code Challenge 생성 (SHA256 해시)
↓
3. [클라이언트 → 인가서버] 인가 요청 (code_challenge 포함)
↓
4. [사용자 ↔ 인가서버] 로그인 및 권한 승인
↓
5. [인가서버 → 클라이언트] 인가코드 발급 (code_challenge 저장)
↓
6. [클라이언트 → 인가서버] 토큰 요청 (code_verifier 포함)
↓
7. [인가서버] code_challenge vs code_verifier 검증
↓
8. [인가서버 → 클라이언트] Access Token 발급
핵심 보안 메커니즘
- 인가코드 요청 시:
code_challenge
전송 (공개 가능) - 토큰 교환 시:
code_verifier
전송 (비밀값) - 인가 서버에서 SHA256(code_verifier) === code_challenge 검증
OAuth2 PKCE Flow 실전 구현
웹 애플리케이션에서의 완전한 PKCE 구현 (JavaScript)
1단계: 로그인 시작
// 로그인 버튼 클릭 시 실행
async function startOAuth2Login() {
// PKCE 파라미터 생성
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = crypto.randomUUID(); // CSRF 방지
// 세션에 저장
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
// Google OAuth2 인가 URL로 리다이렉트
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
`response_type=code&` +
`client_id=your-client-id&` +
`redirect_uri=https://yourapp.com/callback&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256&` +
`state=${state}&` +
`scope=openid profile email`;
window.location.href = authUrl;
}
2단계: 콜백 처리 및 토큰 교환
// /callback 페이지에서 실행
async function handleOAuth2Callback() {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
// State 검증 (CSRF 방지)
if (state !== sessionStorage.getItem('oauth_state')) {
alert('보안 오류: State 불일치');
return;
}
// 토큰 교환
const codeVerifier = sessionStorage.getItem('code_verifier');
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: 'your-client-id',
code: code,
redirect_uri: 'https://yourapp.com/callback',
code_verifier: codeVerifier
})
});
const tokens = await response.json();
localStorage.setItem('access_token', tokens.access_token);
// 메인 페이지로 이동
window.location.href = '/dashboard';
}
모바일 앱에서의 PKCE 구현
Android (Kotlin) - 핵심 코드
class OAuth2PKCEHelper {
private lateinit var codeVerifier: String
fun startAuthFlow(): String {
// PKCE 파라미터 생성
codeVerifier = generateCodeVerifier()
val codeChallenge = generateCodeChallenge(codeVerifier)
// 인가 URL 구성
return Uri.parse("https://accounts.google.com/o/oauth2/v2/auth")
.buildUpon()
.appendQueryParameter("response_type", "code")
.appendQueryParameter("client_id", "your-android-client-id")
.appendQueryParameter("redirect_uri", "com.yourapp://oauth/callback")
.appendQueryParameter("code_challenge", codeChallenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("scope", "openid profile email")
.build().toString()
}
private fun generateCodeVerifier(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun generateCodeChallenge(verifier: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray())
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
}
}
React Native - 라이브러리 활용
// npm install react-native-app-auth
import { authorize } from 'react-native-app-auth';
const config = {
issuer: 'https://accounts.google.com',
clientId: 'your-client-id',
redirectUrl: 'com.yourapp://oauth/callback',
scopes: ['openid', 'profile', 'email']
// PKCE는 자동으로 처리됨
};
async function loginWithOAuth2() {
try {
const result = await authorize(config);
console.log('Access Token:', result.accessToken);
return result;
} catch (error) {
console.error('로그인 실패:', error);
}
}
PKCE 통신 흐름 다이어그램
클라이언트 앱 인가 서버 리소스 서버
| | |
|-- 1. 인가 요청 -----------> | |
| (code_challenge) | |
| |-- 2. 사용자 인증 --------> |
| |<-- 3. 인증 완료 --------- |
|<-- 4. 인가코드 ------------ | |
| | |
|-- 5. 토큰 요청 -----------> | |
| (code_verifier) | |
| |-- 6. PKCE 검증 ---------> |
|<-- 7. Access Token -------| |
| | |
|-- 8. API 요청 -----------------------------------> |
| (Bearer Token) | |
|<-- 9. 리소스 데이터 ------------------------------ |
주요 보안 포인트
- Step 1:
code_challenge
만 전송 (공개값) - Step 5:
code_verifier
전송 (비밀값) - Step 6: 서버에서 SHA256(code_verifier) === code_challenge 검증
서버에서의 PKCE 검증 구현
Python Flask 서버 - 핵심 검증 로직
from flask import Flask, request, jsonify
import hashlib, base64, secrets
app = Flask(__name__)
@app.route('/oauth/token', methods=['POST'])
def token_endpoint():
"""토큰 엔드포인트 - PKCE 검증 포함"""
code = request.form.get('code')
code_verifier = request.form.get('code_verifier')
client_id = request.form.get('client_id')
# 인가코드로 저장된 code_challenge 조회 (실제로는 DB에서)
stored_challenge = get_stored_challenge(code) # 구현 필요
# PKCE 검증
if verify_pkce(code_verifier, stored_challenge):
# 토큰 발급
access_token = secrets.token_urlsafe(64)
return jsonify({
'access_token': access_token,
'token_type': 'Bearer',
'expires_in': 3600
})
else:
return jsonify({'error': 'invalid_grant'}), 400
def verify_pkce(code_verifier, code_challenge):
"""PKCE 검증 함수"""
computed_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).decode().rstrip('=')
return computed_challenge == code_challenge
Node.js Express 서버 - 핵심 검증 로직
const express = require('express');
const crypto = require('crypto');
const app = express();
app.post('/oauth/token', async (req, res) => {
const { grant_type, code, code_verifier, client_id } = req.body;
// 필수 파라미터 확인
if (grant_type !== 'authorization_code' || !code || !code_verifier) {
return res.status(400).json({ error: 'invalid_request' });
}
// 저장된 code_challenge 조회 (실제로는 DB에서)
const storedChallenge = getStoredChallenge(code); // 구현 필요
// PKCE 검증
if (verifyPKCE(code_verifier, storedChallenge)) {
// 토큰 발급
const accessToken = crypto.randomBytes(32).toString('hex');
res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600
});
} else {
res.status(400).json({ error: 'invalid_grant' });
}
});
function verifyPKCE(codeVerifier, codeChallenge) {
const hash = crypto.createHash('sha256').update(codeVerifier).digest();
const computedChallenge = hash.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return computedChallenge === codeChallenge;
}
OAuth2 Best Practice 및 보안 고려사항
PKCE 보안 강화 방법
1. 안전한 Code Verifier 생성 규칙
// 보안이 강화된 Code Verifier 생성
function generateSecureCodeVerifier() {
// 최소 43자, 최대 128자의 랜덤 문자열
const array = new Uint8Array(64);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
.substring(0, 128); // 최대 길이 제한
}
// State 파라미터로 CSRF 방지
function generateSecureState() {
return crypto.randomUUID() + '-' + Date.now();
}
// 토큰 응답 검증
function validateTokenResponse(response) {
if (!response.access_token || !response.token_type) {
throw new Error('필수 필드 누락');
}
if (response.token_type.toLowerCase() !== 'bearer') {
throw new Error('유효하지 않은 토큰 타입');
}
return true;
}
2. 보안 메커니즘 상세 분석
📋 보안 계층 구조:
기본 OAuth2 플로우 PKCE 강화 플로우
================ ================
1. 클라이언트 시크릿 의존 1. 동적 Code Verifier 생성
(정적, 탈취 위험) (세션별 고유값)
↓ ↓
2. 인가코드 탈취 시 2. 인가코드 + Code Challenge
즉시 토큰 획득 가능 (해시값만 노출)
↓ ↓
3. 보안 취약점 존재 3. Code Verifier 없으면
토큰 교환 불가능
↓
4. 공격자가 인가코드를
탈취해도 무효화
공격 시나리오별 방어 메커니즘
- 인가코드 가로채기 공격
- 일반 OAuth2: 클라이언트 시크릿만 있으면 토큰 획득 가능
- PKCE: Code Verifier가 없으면 토큰 교환 불가능
- 앱 리버스 엔지니어링
- 일반 OAuth2: 하드코딩된 시크릿 노출 위험
- PKCE: 동적 생성되는 Code Verifier로 보안 강화
- 중간자 공격 (MITM)
- 일반 OAuth2: 인가코드 탈취 시 취약
- PKCE: SHA256 해시 검증으로 방어
실전 프로젝트: 완전한 PKCE 클라이언트
Production-ready PKCE 클라이언트 (JavaScript)
/**
* 2025 OAuth2 가이드에 따른 완전한 PKCE 클라이언트
*/
class OAuth2PKCEClient {
constructor(config) {
this.clientId = config.clientId;
this.authEndpoint = config.authEndpoint;
this.tokenEndpoint = config.tokenEndpoint;
this.redirectUri = config.redirectUri;
this.scopes = config.scopes || ['openid'];
}
async authenticate() {
// 기존 토큰 확인
const existingToken = localStorage.getItem('access_token');
if (existingToken && !this.isTokenExpired(existingToken)) {
return { access_token: existingToken };
}
// 새로운 인증 시작
return await this.startAuthFlow();
}
async startAuthFlow() {
// PKCE 파라미터 생성
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
const state = crypto.randomUUID();
// 세션 저장
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
sessionStorage.setItem('pkce_state', state);
// 인가 URL 구성
const authUrl = this.buildAuthUrl(codeChallenge, state);
// 리다이렉트
window.location.href = authUrl;
}
async handleCallback(callbackUrl) {
const url = new URL(callbackUrl);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
// State 검증
const storedState = sessionStorage.getItem('pkce_state');
if (state !== storedState) {
throw new Error('State mismatch - CSRF attack 가능성');
}
// 토큰 교환
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
return await this.exchangeTokens(code, codeVerifier);
}
async exchangeTokens(code, codeVerifier) {
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.clientId,
code: code,
redirect_uri: this.redirectUri,
code_verifier: codeVerifier
})
});
const tokens = await response.json();
// 토큰 저장
localStorage.setItem('access_token', tokens.access_token);
if (tokens.refresh_token) {
localStorage.setItem('refresh_token', tokens.refresh_token);
}
// 세션 정리
sessionStorage.removeItem('pkce_code_verifier');
sessionStorage.removeItem('pkce_state');
return tokens;
}
generateCodeVerifier() {
const array = new Uint8Array(64);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
.substring(0, 128);
}
async generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
buildAuthUrl(codeChallenge, state) {
const params = new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
redirect_uri: this.redirectUri,
scope: this.scopes.join(' '),
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: state
});
return `${this.authEndpoint}?${params.toString()}`;
}
}
// 사용 예제
const oauth2Client = new OAuth2PKCEClient({
clientId: 'your-client-id',
authEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
redirectUri: 'https://yourapp.com/callback',
scopes: ['openid', 'profile', 'email']
});
// 로그인 시작
document.getElementById('loginBtn').addEventListener('click', () => {
oauth2Client.authenticate();
});
// 콜백 처리
if (window.location.pathname === '/callback') {
oauth2Client.handleCallback(window.location.href)
.then(tokens => {
console.log('인증 성공:', tokens);
window.location.href = '/dashboard';
})
.catch(error => {
console.error('인증 실패:', error);
});
}
마무리: 2025년 OAuth2 PKCE 적용 가이드
OAuth2 PKCE Flow는 현대적인 애플리케이션에서 필수적인 인증 방식이 되었습니다.
특히 모바일 인증과 SPA 환경에서는 기존 OAuth2의 보안 한계를 극복하는 핵심 기술입니다.
주요 장점 정리
- 향상된 보안성: 인가코드 탈취 공격 방지
- 클라이언트 시크릿 불필요: 모바일 앱에 최적화
- 표준 프로토콜: RFC 7636 기반의 안정적인 표준
- 광범위한 지원: 주요 OAuth2 제공자들의 지원
구현 시 체크리스트
- ✅ Code verifier 길이 (43-128자) 준수
- ✅ SHA256 해시 방식 사용
- ✅ State 파라미터로 CSRF 방지
- ✅ 토큰 안전한 저장소 활용
- ✅ 에러 처리 및 로깅 구현
- ✅ 토큰 만료 시 자동 갱신
OAuth2 PKCE를 올바르게 구현하면 사용자 데이터를 보호하면서도 뛰어난 사용자 경험을 제공할 수 있습니다.
이 가이드를 참조하여 안전하고 효율적인 OAuth2 인증 시스템을 구축하시기 바랍니다.
참고 자료
- RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients
- OAuth 2.0 Security Best Current Practice
- OpenID Connect Core 1.0
같이 보면 좋은 글
스프링 시큐리티와 보안 가이드: JWT vs OAuth2 vs Session – 인증 방식 비교와 적용 전략
인증 방식 핵심 개념 이해웹 애플리케이션에서 사용자를 인증하는 방법은 크게 세 가지 주요 방식으로 나뉩니다:🔐 Session 기반 인증: 서버가 사용자 정보를 메모리나 데이터베이스에 저장하고,
notavoid.tistory.com
Spring Boot에서 소셜 로그인(OAuth2) 구현하기 - 구글, 네이버, 카카오
📌 소셜 로그인을 사용해야 하는 이유사이드 프로젝트나 스타트업 서비스를 개발할 때 사용자 인증은 필수적인 기능입니다.하지만 전통적인 회원가입/로그인 방식은 사용자에게 귀찮은 절차
notavoid.tistory.com
[Spring Security] Spring Security의 FilterChain 구조 완벽 이해
안녕하세요! 오늘은 Spring Security의 핵심 엔진인 FilterChain에 대해 실무 관점에서 깊이 있게 알아보겠습니다.대규모 운영 환경에서 실제로 마주하는 성능 이슈와 해결 방법을 중심으로 설명드리겠
notavoid.tistory.com