OAuth 2.0 PKCE 흐름 완전 해부: SPA와 모바일 앱에서 Authorization Code를 안전하게 쓰는 법

퍼블릭 클라이언트는 비밀이 없다, 그래서 흐름 자체가 방어선이어야 한다
OAuth 2.0을 처음 도입할 때 우리 개발팀은 단일 페이지 애플리케이션에 Implicit Flow를 적용했습니다. 당시 공식 문서들은 SPA에서 "client_secret을 안전하게 보관할 수 없으니 Implicit을 쓰라"고 안내했고, access token이 redirect_uri의 fragment로 바로 내려왔습니다. 토큰을 즉시 받을 수 있어 편했지만, 2026년 현재 이 방식은 OAuth Security BCP에 의해 명시적으로 사용 금지 권고 대상이 되었습니다.
PKCE(Proof Key for Code Exchange)는 그 공백을 메우는 메커니즘입니다. client_secret 없이도 Authorization Code Flow를 안전하게 실행할 수 있도록 수학적 바인딩을 추가합니다.
1. Implicit Flow가 폐기된 이유
Implicit Flow는 authorization endpoint가 access token을 redirect_uri의 URL fragment에 직접 담아 돌려주는 방식입니다. 편의를 위해 authorization code 교환 단계를 생략했지만, 이 편의가 여러 공격 경로를 열어놓았습니다.
첫째, 토큰이 URL에 노출됩니다. Fragment는 서버로 전송되지 않지만 브라우저 history, HTTP Referer 헤더, 제3자 JavaScript 코드에는 읽힐 수 있습니다.
둘째, 토큰 바인딩이 없습니다. Authorization server는 어떤 클라이언트가 토큰을 사용하는지 확인할 방법이 없습니다.
셋째, refresh token을 발급하지 않는 것이 관행이었습니다. Access token이 만료될 때마다 인가 서버로 돌아가야 했고, silent redirect 패턴이 그 대안으로 쓰였지만 iframe을 이용한 우회 공격에 취약했습니다.
OAuth Security BCP는 Implicit Flow를 폐기하고 SPA와 모바일 앱 모두에서 PKCE를 사용하는 Authorization Code Flow를 권고합니다.
2. PKCE 개념: code_verifier와 code_challenge
PKCE(RFC 7636)의 핵심은 단순합니다. 클라이언트가 인가 요청을 보내기 전에 무작위 문자열(code_verifier)을 생성하고, 그것의 SHA-256 해시인 code_challenge를 함께 전송합니다. 나중에 authorization code를 access token으로 교환할 때 원본 code_verifier를 제출하면 서버가 해시를 재계산해 일치 여부를 검증합니다.
async function generatePkceChallenge(): Promise<{ verifier: string; challenge: string }> {
// RFC 7636 §4.1: verifier는 43~128자 URL-safe 문자열이어야 한다
const array = new Uint8Array(48);
crypto.getRandomValues(array);
const verifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
// RFC 7636 §4.2: S256 method
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return { verifier, challenge };
}
code_challenge_method=plain은 해시 없이 verifier를 그대로 전송하는 방식으로, 네트워크 도청에 취약합니다. 반드시 S256을 사용해야 합니다.
3. 인가 서버 선택 기준: Auth0, Keycloak, Cognito 비교
| 항목 | Auth0 | Keycloak | Amazon Cognito |
|---|---|---|---|
| 운영 형태 | SaaS | Self-hosted (오픈소스) | AWS 관리형 |
| PKCE 기본 지원 | 기본 활성화 | 2.1+ 기본 활성화 | 기본 지원 |
| Refresh Token Rotation | 기본 지원 | 지원 | 지원 |
| DPoP 지원 | Enterprise 플랜 이상 | 22+ 버전 실험적 | 미지원 |
| 무료 한도 | 월 7,500 MAU | 무제한 | 월 50,000 MAU |
| 커스터마이징 | 제한적 (Actions) | 매우 유연 | 제한적 |
선택 기준 요약: 팀이 인프라를 직접 관리하기 어렵고 빠르게 시작해야 한다면 Auth0, AWS 생태계 중심으로 운영하고 비용 효율이 중요하다면 Cognito, 금융·의료처럼 규제가 강하거나 완전한 데이터 소유권이 필요하다면 Keycloak 자체 호스팅이 현실적입니다.
4. state 파라미터로 CSRF 방어
PKCE가 code 가로채기를 막는다면, state 파라미터는 인가 요청 자체가 우리 앱에서 시작됐음을 검증합니다.
function buildAuthorizationUrl(
authEndpoint: string,
clientId: string,
redirectUri: string,
challenge: string,
): string {
const stateArray = new Uint8Array(32);
crypto.getRandomValues(stateArray);
const state = btoa(String.fromCharCode(...stateArray))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: 'openid profile email offline_access',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
return `${authEndpoint}?${params.toString()}`;
}
function handleCallback(params: URLSearchParams, storedVerifier: string): void {
const returnedState = params.get('state');
const storedState = sessionStorage.getItem('oauth_state');
if (!returnedState || returnedState !== storedState) {
sessionStorage.removeItem('oauth_state');
throw new Error('OAuth state mismatch: potential CSRF attack');
}
sessionStorage.removeItem('oauth_state');
}
5. token_endpoint_auth_method 설정과 클라이언트 인증
SPA와 네이티브 모바일 앱은 "퍼블릭 클라이언트"입니다. client_secret을 안전하게 보관할 수 없기 때문에 token endpoint에서 클라이언트 인증을 할 수 없습니다.
PKCE를 사용하는 퍼블릭 클라이언트의 올바른 등록 설정:
client_type: publictoken_endpoint_auth_method: nonegrant_types: authorization_code, refresh_tokenresponse_types: codepkce_required: true
서버사이드 컴포넌트(BFF, 백엔드 API)는 기밀 클라이언트입니다. 이 경우 client_secret_basic(Basic Auth)이나 private_key_jwt(JWT assertion)를 사용할 수 있습니다.
6. 액세스 토큰 저장 위치 비교
| 저장 위치 | XSS 노출 | CSRF 노출 | 권장 사용 |
|---|---|---|---|
| localStorage | 높음 | 없음 | 사용 금지 권고 |
| sessionStorage | 높음 | 없음 | 단기 임시 보관 |
| 메모리 (JS 변수) | 낮음 | 없음 | Access token 권장 |
| HttpOnly Cookie | 없음 | 있음 | Refresh token 권장 |
현재 업계 권장 전략은 다음과 같습니다. Access token은 메모리(JavaScript 변수)에만 보관합니다. Refresh token은 HttpOnly Cookie에 보관하고, CSRF 방어를 함께 적용합니다.
localStorage에 access token을 저장하는 것은 2026년 현재 공개적으로 사용 금지가 권고됩니다. 더 자세한 쿠키·세션 보안 설계는 웹 인증 세션 보안에서 다룹니다.
7. Silent Renewal vs iframe vs Refresh Token Rotation
Silent Renewal (iframe 방식): 숨겨진 iframe을 생성해 인가 서버의 /authorize 엔드포인트를 호출합니다. 2026년 현재 이 방식은 Chrome의 서드파티 쿠키 차단 정책과 ITP 때문에 점점 신뢰성을 잃고 있습니다.
Refresh Token Rotation: Refresh token으로 새 access token을 요청할 때마다 새 refresh token도 함께 발급하고 기존 refresh token을 즉시 무효화하는 방식입니다. oauth.net/2/pkce에서도 이 방식을 SPA의 기본 갱신 전략으로 권장합니다.
현재 권장하는 조합은 Refresh Token Rotation + HttpOnly Cookie 저장 + PKCE입니다.
8. DPoP(Demonstrating Proof of Possession) 소개
PKCE가 인가 코드 가로채기를 막는다면, DPoP(RFC 9449) 는 발급된 access token 자체가 특정 클라이언트에 바인딩되도록 합니다.
동작 원리: 클라이언트는 비대칭 키 쌍을 생성하고, token 요청 시 JWK 공개키와 함께 서명된 DPoP 증명 JWT를 DPoP 헤더로 전송합니다. 인가 서버는 이 공개키 thumbprint를 access token에 바인딩합니다(cnf.jkt 클레임).
금융 API나 오픈뱅킹처럼 FAPI 2.0 준수가 필요한 환경에서는 DPoP가 사실상 필수입니다. JWT claims 검증과 관련된 함정은 JWT Claims 검증 함정과 none 알고리즘 공격에서 별도로 다룹니다.
9. BFF(Backend for Frontend) 패턴
BFF 패턴의 핵심은 SPA가 OAuth 토큰을 전혀 보지 않는 것입니다. 브라우저와 인가 서버 사이에 전용 백엔드(BFF)가 들어가고, BFF가 모든 토큰 처리를 담당합니다.
import express from 'express';
import { Issuer, generators } from 'openid-client';
import session from 'express-session';
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 30 * 60 * 1000,
},
}));
app.get('/auth/login', async (req, res) => {
const issuer = await Issuer.discover(process.env.OIDC_ISSUER!);
const client = new issuer.Client({
client_id: process.env.CLIENT_ID!,
redirect_uris: [`${process.env.APP_URL}/auth/callback`],
response_types: ['code'],
token_endpoint_auth_method: 'none',
});
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const state = generators.state();
(req.session as any).codeVerifier = codeVerifier;
(req.session as any).state = state;
const authUrl = client.authorizationUrl({
scope: 'openid profile email offline_access',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
res.redirect(authUrl);
});
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query as { code: string; state: string };
const session = req.session as any;
if (state !== session.state) {
return res.status(400).json({ error: 'state_mismatch' });
}
const issuer = await Issuer.discover(process.env.OIDC_ISSUER!);
const client = new issuer.Client({ client_id: process.env.CLIENT_ID! });
const tokenSet = await client.oauthCallback(
`${process.env.APP_URL}/auth/callback`,
{ code, state },
{ code_verifier: session.codeVerifier, state: session.state },
);
session.accessToken = tokenSet.access_token;
session.refreshToken = tokenSet.refresh_token;
session.expiresAt = tokenSet.expires_at;
delete session.codeVerifier;
delete session.state;
res.redirect('/');
});
BFF 패턴의 장점은 명확합니다. XSS가 발생해도 SPA는 access token 자체를 갖고 있지 않습니다.
Next.js의 Route Handler나 Nuxt의 server routes를 BFF로 활용하면 별도 서비스 없이 같은 저장소에서 관리할 수 있어 현실적인 절충점이 됩니다.
10. 실전 구현: oidc-client-ts와 PKCE 흐름 코드
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
const userManager = new UserManager({
authority: 'https://auth.example.com',
client_id: 'my-spa-client',
redirect_uri: `${window.location.origin}/callback`,
post_logout_redirect_uri: `${window.location.origin}/`,
response_type: 'code',
scope: 'openid profile email offline_access',
automaticSilentRenew: true,
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
revokeTokensOnSignout: true,
});
async function login(): Promise<void> {
await userManager.signinRedirect({
state: { returnTo: window.location.pathname },
});
}
async function handleCallback(): Promise<void> {
const user = await userManager.signinRedirectCallback();
const returnTo = (user.state as any)?.returnTo ?? '/';
window.location.replace(returnTo);
}
async function callApi(endpoint: string): Promise<Response> {
const user = await userManager.getUser();
if (!user || user.expired) {
await userManager.signinSilent().catch(() => userManager.signinRedirect());
return callApi(endpoint);
}
return fetch(endpoint, {
headers: {
Authorization: `Bearer ${user.access_token}`,
'Content-Type': 'application/json',
},
credentials: 'include',
});
}
결론
OAuth 2.0 PKCE 흐름은 Implicit의 위험을 제거하면서도 SPA와 모바일 앱에서 실용적으로 적용 가능한 표준이 됐습니다.
- PKCE S256 강제 적용: 인가 서버에서 퍼블릭 클라이언트에 대해
pkce_required=true와code_challenge_method=S256을 강제했는가. - state 검증 누락 없음: 인가 콜백에서 반드시
state파라미터를 저장된 값과 비교합니다. - Access token 저장 위치: localStorage에 access token을 보관하지 않습니다.
- Refresh Token Rotation 활성화: 인가 서버에서 refresh token rotation을 활성화합니다.
token_endpoint_auth_method: none명시: SPA·모바일 클라이언트 등록 시 퍼블릭 클라이언트로 명시합니다.