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
'네트워크와 프로토콜 완벽 가이드' 카테고리의 다른 글
MQTT 프로토콜: 개념, 동작 원리, 실전 활용과 장단점 완전정리 (0) | 2025.07.28 |
---|---|
포트 포워딩: 원리, 설정 방법, 실전 활용 및 보안 팁까지 완전정리 (0) | 2025.07.27 |
인터넷 없이 메시지 전송? 잭 도시의 비트챗(Bitchat)과 오프라인 메쉬 네트워크 혁신 (0) | 2025.07.08 |
2025년 최신 무료VPN 추천 TOP7 – 개인정보 보호와 속도까지 잡은 무료 VPN 서비스 비교 (0) | 2025.06.29 |
클라우드플레어, 7.3Tbps에 달하는 사상 최대 규모의 DDoS 공격 보고 - 네트워크 보안의 새로운 전환점 (0) | 2025.06.25 |