서론:
모바일 앱과 SPA(Single Page Application)이 보편화되면서 OAuth 2.0 인증 시스템은 새로운 보안 과제에 직면했습니다. 기존 Authorization Code Flow는 클라이언트 시크릿을 안전하게 저장할 수 있는 웹 서버 환경을 전제로 설계되었으나, 모바일 앱과 SPA는 소스 코드가 노출되는 환경에서 운영됩니다. 이러한 환경에서 클라이언트 시크릿을 저장하는 것은 불가능하며, 권한 코드 탈취(Interception) 공격에 취약합니다. OAuth PKCE(Proof Key for Code Exchange, "픽시"로 발음)는 RFC 7636으로 표준화된 이 문제를 해결하기 위한 확장 메커니즘입니다. PKCE는 동적으로 생성되는 암호화 키를 사용하여 권한 코드의 소유권을 증명함으로써, 악성 앱이나 공격자가 가로챈 권한 코드를 악용하는 것을 방지합니다. 본 칼럼에서는 PKCE의 원리, 사용 사례, 구현 방법을 살펴보고 실무 적용 시 고려해야 할 점을 정리합니다.
본론:
1. PKCE의 기술적 배경 및 작동 원리
문제 정의: 권한 코드 탈취 공격
PKCE가 해결하는 핵심 문제는 Authorization Code Interception Attack입니다. 이 공격의 시나리오는 다음과 같습니다:
- 사용자가 정상 앱에서 인증을 요청
- 권한 서버가 권한 코드를 발급하여 리디렉션
- 악성 앱이 동일한 커스텀 URI 스킴(예:
myapp://)을 등록하여 권한 코드를 가로챔 - 공격자가 가로챈 권한 코드를 사용하여 액세스 토큰 획득
이 공격이 성립하기 위한 전제 조건:
- 공격자가 악성 앱을 설치하고 커스텀 URI 스킴을 등록 가능
- 동일한 client_id를 사용하는 모든 앱 인스턴스가 동일한 시크릿을 공유 (또는 시크릿이 없음)
- 권한 코드가 TLS 보호되지 않은 앱 간 통신 경로에서 전송됨
PKCE의 해결 방식: 동적 키 기반 증명
PKCE는 다음과 같은 4단계 프로토콜을 사용하여 공격을 방지합니다:
- Code Verifier 생성: 클라이언트가 43-128자 길이의 암호화적 랜덤 문자열(
code_verifier) 생성 - Code Challenge 생성:
code_verifier를 변환한 값(code_challenge) 생성 S256방식:code_challenge = BASE64URL(SHA256(code_verifier))plain방식:code_challenge = code_verifier(호환성용, 권장되지 않음)- 인증 요청:
code_challenge와code_challenge_method를 권한 서버로 전송 - 토큰 요청: 권한 코드와 함께
code_verifier를 토큰 엔드포인트로 전송, 서버가 검증
이 방식의 핵심은 code_verifier가 TLS 보호된 채널(토큰 요청)에서만 전송된다는 점입니다. 악성 앱이 권한 코드를 가로채더라도, code_verifier를 알지 못하면 토큰을 획득할 수 없습니다.
2. 사용 사례 및 적용 범위
모바일 애플리케이션 (Native Apps)
PKCE가 가장 먼저 도입된 사용 사례입니다. iOS, Android 네이티브 앱은 다음 이유로 PKCE 필수:
- 클라이언트 시크릿 저장 불가: 앱의 바이너리 리버스 엔지니어링으로 시크릿 노출
- 커스텀 URI 스킴 경쟁: 여러 앱이 동일한 스킴 등록 가능, 권한 코드 가로챔 위험
- 앱 간 통신 취약성: OS 내부 앱 간 통신이 TLS로 보호되지 않음
Single Page Applications (SPA)
React, Vue, Angular 등 프론트엔드 프레임워크 기반 SPA는 다음 이유로 PKCE 적용:
- 브라우저 환경 노출: 모든 코드가 클라이언트에서 실행, 시크릿 저장 불가
- Implicit Flow 폐기: OAuth 2.1에서 Implicit Flow가 권장되지 않음
- CSRF 방어: PKCE는 CSRF 공격에 대한 추가적 보호 계층 제공
웹 애플리케이션 (Confidential Clients)
흥미롭게도, PKCE는 confidential 클라이언트(서버 사이드 시크릿 보유)에도 권장됩니다:
- 추가 보안 계층: 클라이언트 시크릿이 노출되더라도 PKCE가 방어 계층 제공
- 권한 코드 재사용 방지: 각 인증 요청마다 고유한
code_verifier사용 - OAuth 2.1 표준: OAuth 2.1 모든 클라이언트 유형에 PKCE 요구
PKCE가 아닌 경우
- 서버 간 통신: Machine-to-Machine OAuth는
client_secret_basic또는private_key_jwt사용 - 기존 시스템 호환성 문제: 레거시 시스템이 PKCE를 지원하지 않는 경우
3. 구현 방법 및 베스트 프랙티스
Code Verifier 생성 구현 (JavaScript)
// 암호학적 랜덤 코드 검증자 생성 (43-128자)
function generateCodeVerifier() {
const array = new Uint8Array(32);
window.crypto.getRandomValues(array);
const verifier = base64UrlEncode(array);
if (verifier.length < 43 || verifier.length > 128) {
throw new Error('Invalid verifier length');
}
return verifier;
}
// SHA256 해시 및 Base64URL 인코딩
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await window.crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(hash);
}
// Base64URL 인코딩 (패딩 제거, 문자열 치환)
function base64UrlEncode(buffer) {
const str = String.fromCharCode(...new Uint8Array(buffer));
return btoa(str)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// 사용 예
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
인증 요청 (Authorization Request)
const params = new URLSearchParams({
response_type: 'code',
client_id: 'your-client-id',
redirect_uri: 'https://your-app.com/callback',
scope: 'openid profile email',
state: generateRandomState(), // CSRF 방어를 위한 상태 값
code_challenge: codeChallenge,
code_challenge_method: 'S256' // MUST use S256
});
// 브라우저 리디렉션
window.location.href = `https://auth-server.com/authorize?${params.toString()}`;
토큰 요청 (Token Request)
// 콜백 URL에서 권한 코드 추출
const urlParams = new URLSearchParams(window.location.search);
const authorizationCode = urlParams.get('code');
// 토큰 엔드포인트로 요청
const tokenResponse = await fetch('https://auth-server.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: 'https://your-app.com/callback',
client_id: 'your-client-id',
code_verifier: codeVerifier // 원본 검증자 전송
})
});
const tokens = await tokenResponse.json();
서버 측 구현 (Node.js/Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
// 인증 엔드포인트: code_challenge 저장
app.get('/authorize', (req, res) => {
const { code_challenge, code_challenge_method } = req.query;
if (!code_challenge) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'PKCE code challenge required'
});
}
// 권한 코드와 code_challenge를 암호화된 형태로 저장
const authCode = crypto.randomBytes(32).toString('base64url');
const storedData = {
code_challenge,
code_challenge_method: code_challenge_method || 'plain',
expiresAt: Date.now() + 10 * 60 * 1000 // 10분 유효
};
// DB 또는 캐시에 저장 (예: Redis)
await redis.setex(authCode, 600, JSON.stringify(storedData));
// 리디렉션
res.redirect(`${req.query.redirect_uri}?code=${authCode}&state=${req.query.state}`);
});
// 토큰 엔드포인트: code_verifier 검증
app.post('/token', async (req, res) => {
const { code, code_verifier, grant_type } = req.body;
if (grant_type !== 'authorization_code') {
return res.status(400).json({ error: 'unsupported_grant_type' });
}
// 저장된 데이터 조회
const storedDataStr = await redis.get(code);
if (!storedDataStr) {
return res.status(400).json({ error: 'invalid_grant' });
}
const storedData = JSON.parse(storedDataStr);
// code_verifier 검증
let expectedChallenge;
if (storedData.code_challenge_method === 'S256') {
expectedChallenge = crypto
.createHash('sha256')
.update(code_verifier)
.digest('base64url');
} else {
expectedChallenge = code_verifier; // plain 방식
}
if (storedData.code_challenge !== expectedChallenge) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Code verifier mismatch' });
}
// 검증 성공, 액세스 토큰 발급
const accessToken = generateAccessToken();
const refreshToken = generateRefreshToken();
// 사용된 코드 삭제 (재사용 방지)
await redis.del(code);
res.json({
access_token: accessToken,
refresh_token: refreshToken,
token_type: 'Bearer',
expires_in: 3600
});
});
app.listen(3000, () => console.log('Auth server running on port 3000'));
구현 시 주의사항
- 엔트로피 보장:
code_verifier는 최소 32바이트 (256비트) 무작위성 필요 - S256 강제: 서버는
S256방식을 강제하고,plain은 호환성 이슈 시에만 허용 - 상태 관리:
code_challenge와code_verifier는 일회성 사용, 발급 후 즉시 폐기 - TLS 필수: 모든 OAuth 통신은 HTTPS로 보호되어야 함
- state 파라미터: CSRF 방어를 위해
state파라미터는 PKCE와 별도로 필수
4. 베스트 프랙티스 및 권고사항
엔터프라이즈 환경 적용 가이드
| 단계 | 즉시 대응 (24시간 이내) | 단기 대응 (72시간 이내) | 장기 대응 (1주 이내) |
|---|---|---|---|
| 위협 평가 | 모든 SPA/모바일 앱의 OAuth 구현 방식 식별 | PKCE 미사용 앱의 취약성 평가 | 전체 보안 아키텍처 재검토 |
| PKCE 도입 | 새로운 모바일/SPA 앱은 PKCE 필수 적용 | 기존 앱의 PKCE 마이그레이션 계획 수립 | 모든 OAuth 클라이언트에 PKCE 적용 |
| 서버 설정 | 권한 서버가 PKCE 지원하는지 확인 | 인증/토큰 엔드포인트 PKCE 검증 로직 추가 | OAuth 2.1 표준으로 업그레이드 |
| 테스트 | 개발 환경에서 PKCE 기능 테스트 | 통합 테스트 및 보안 검증 | 침투 테스트 및 감사 수행 |
| 문서화 | 개발자 문서에 PKCE 필수 사항 명시 | 아키텍처 문서 업데이트 | 보안 정책 및 절차 문서화 |
OAuth 2.1 및 PKCE 관련 보안 표준 준수
OAuth 2.1 Working Draft는 PKCE를 다음과 같이 요구합니다:
- 필수적용: 모든 Authorization Code Grant에서 PKCE 사용
- S256 방식 강제:
code_challenge_method값은 반드시S256이어야 함 - plain 방식 폐기: 호환성 이슈가 없는 한 plain 방식 사용 금지
- Public Client: 시크릿이 없는 클라이언트는 PKCE 필수
모니터링 및 감시
PKCE 구현 후 다음 보안 이벤트 모니터링 권장:
code_challenge누락 요청: 가능한 공격 시도code_verifier검증 실패: 권한 코드 재사용 또는 공격 시도- 동일
code_challenge다중 사용: 중복 요청 또는 재생 공격 - 비정상적인 토큰 요청 빈도: 무차별 대입 공격 시도
도구 및 라이브러리
- JavaScript/TypeScript:
oidc-client-ts,auth0-js,react-oidc-context - Python:
authlib,requests-oauthlib - Java:
Spring Security OAuth,Apache Oltu - .NET:
IdentityServer,Microsoft.Identity.Client - 테스트 도구: OAuth 2.0 Playground
결론:
OAuth PKCE는 모바일 앱과 SPA의 보안 취약점을 해결하는 필수 메커니즘입니다. RFC 7636으로 표준화된 PKCE는 동적으로 생성되는 암호화 키를 통해 권한 코드 탈취 공격을 효과적으로 방지합니다. 모든 OAuth 2.0 클라이언트, 특히 public 클라이언트는 PKCE를 적용하여 보안 강화를 달성해야 합니다. 엔터프라이즈 환경에서는 단계적 마이그레이션 계획을 수립하고, OAuth 2.1 표준을 준수하며, 지속적인 모니터링을 통해 보안 수준을 유지해야 합니다.
대응 방안:
| 위협 레벨 | 즉시 대응 | 단기 대응 | 장기 대응 |
|---|---|---|---|
| [Critical] | 24시간 이내 | 72시간 이내 | 1주 이내 |
| [High] | 48시간 이내 | 1주 이내 | 2주 이내 |
| [Medium] | 72시간 이내 | 2주 이내 | 1개월 이내 |
| [Low] | 1주 이내 | 1개월 이내 | 3개월 이내 |
참고자료:
- RFC 7636: Proof Key for Code Exchange by OAuth Public Clients (https://www.rfc-editor.org/rfc/rfc7636)
- OAuth 2.0 for Native and Mobile Apps - Okta Developer (https://developer.okta.com/blog/2018/12/13/oauth-2-for-native-and-mobile-apps)
- OAuth 2.1 Security Best Current Practice - IETF Draft (https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1-bcp/)
- PKCE on oauth.net (https://oauth.net/2/pkce/)
본 콘텐츠는 AI 기술로 생성된 분석 리포트를 포함하고 있습니다. 내용 중 사실과 다르거나 보완이 필요한 정보를 발견하시면 댓글을 통해 소중한 의견 부탁드립니다. 여러분의 피드백은 더 정확한 보안 정보 공유에 큰 도움이 됩니다.
댓글 (0)
댓글을 작성하려면 로그인이 필요합니다.
로그인아직 댓글이 없습니다.
첫 번째 댓글을 작성해보세요!