본문으로 건너뛰기

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

Security25분 읽기본문 12,497

핵심 요약

이 글에서 확인할 내용

퍼블릭 클라이언트는 비밀이 없다, 그래서 흐름 자체가 방어선이어야 한다 OAuth 2.0을 처음 도입할 때 구현 담당 팀은 단일 페이지 애플리케이션에 Implicit Flow를 적용했습니다.

  • 1. Implicit Flow가 폐기된 이유
  • 2. PKCE 개념: codeverifier와 codechallenge
  • 3. 인가 서버 선택 기준: Auth0, Keycloak, Cognito 비교

OAuth2 PKCE Authorization Code flow SPA mobile security

퍼블릭 클라이언트는 비밀이 없다, 그래서 흐름 자체가 방어선이어야 한다

OAuth 2.0을 처음 도입할 때 구현 담당 팀은 단일 페이지 애플리케이션에 Implicit Flow를 적용했습니다. 당시 공식 문서들은 SPA에서 "client_secret을 안전하게 보관할 수 없으니 Implicit을 쓰라"고 안내했고, access token이 redirect_uri의 fragment로 바로 내려왔습니다. 토큰을 즉시 받을 수 있어 편했지만, 현재 이 방식은 OAuth Security BCP에 의해 명시적으로 사용 금지 권고 대상이 되었습니다.

PKCE(Proof Key for Code Exchange)는 그 공백을 메우는 메커니즘입니다. client_secret 없이도 Authorization Code Flow를 안전하게 실행할 수 있도록 수학적 바인딩을 추가합니다.

필자가 속했던 팀이 이 구조를 실제로 마이그레이션한 계기는 다소 극적이었습니다. 외부 보안 감사 업체가 프로덕션 환경을 점검하던 중, 페이지에 삽입된 서드파티 분석 스크립트가 window.location.hash를 읽어 페이지 이동 경로 전체를 자사 로깅 서버로 전송하고 있다는 사실을 발견했습니다. 스크립트 자체는 악의적이지 않았고 단순히 페이지 이동을 추적할 목적으로 URL 전체를 수집했을 뿐이었지만, 결과적으로 Implicit Flow가 fragment에 남겨둔 access token이 제3자 서버 로그에 평문으로 함께 남는 상황이 벌어졌습니다. 이 사건 이후 팀은 약 3개월에 걸쳐 전체 인증 흐름을 Authorization Code + PKCE로 전환했고, 그 과정에서 겪은 실무 함정들을 이 글에 정리했습니다.


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을 이용한 우회 공격에 취약했습니다.

앞서 언급한 사고에서도 정확히 첫 번째 문제가 현실화됐습니다. 브라우저 확장 프로그램이나 서드파티 스크립트는 location.hash를 얼마든지 읽을 수 있고, 페이지 이동을 추적하는 흔한 애널리틱스 로직조차 fragment 값을 무심코 함께 수집할 수 있습니다. 개발자가 "fragment는 서버로 안 간다"는 사실만 믿고 페이지에 삽입된 클라이언트 사이드 코드 전체를 암묵적으로 신뢰하는 순간, 그 신뢰가 곧 새로운 공격 표면이 됩니다.

OAuth Security BCP는 Implicit Flow를 폐기하고 SPA와 모바일 앱 모두에서 PKCE를 사용하는 Authorization Code Flow를 권고합니다.

이 권고는 이제 사실상 표준 자체에 반영되는 단계에 들어섰습니다. 2026년 현재 IETF 초안 상태인 OAuth 2.1(draft-ietf-oauth-v2-1)은 Implicit Flow와 Resource Owner Password Credentials Grant를 사양에서 아예 제거하고, 모든 클라이언트 타입이 PKCE를 포함한 Authorization Code Flow 하나로 수렴하도록 요구합니다. 아직 정식 RFC로 승인되지는 않았지만, Okta·Microsoft Entra ID·Auth0 같은 주요 인가 서버 벤더들은 이미 초안의 요구사항을 선제적으로 구현해 두었습니다. 즉 "PKCE는 선택 사항"이라는 인식은 더 이상 유효하지 않습니다.


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을 사용해야 합니다.

code_verifier 길이가 43~128자로 규정된 이유는 엔트로피 때문입니다. 32바이트(256비트) 무작위 값을 Base64url로 인코딩하면 43자가 나오는데, 이는 무차별 대입에 안전하다고 간주되는 최소 엔트로피입니다. 실무에서 흔히 겪는 문제는 verifier의 '보관 위치'입니다. 리다이렉트 이전에 생성한 verifier를 sessionStorage에 저장해뒀는데, 사용자가 인가 서버 화면에서 새 탭을 열었다가 원래 탭으로 돌아오거나 브라우저를 강제 종료했다가 다시 열면 verifier가 유실되어 콜백 처리 시 invalid_grant 오류가 발생합니다. 이 경우 사용자에게 로그인을 처음부터 다시 요청하는 것 외에는 복구 방법이 없으므로, 콜백 핸들러에서 verifier 부재를 명확히 감지하고 "세션이 만료되어 다시 로그인해 주세요" 수준의 친절한 재시도 안내를 보여주는 UX 설계가 필요합니다. 레거시 클라이언트 호환을 위해 plain을 남겨두는 인가 서버도 있지만, 신규 클라이언트 등록 시에는 인가 서버 설정에서 plain 메서드 자체를 비활성화해 개발자가 실수로 선택할 수 없도록 강제하는 것이 안전합니다.


3. 인가 서버 선택 기준: Auth0, Keycloak, Cognito 비교

항목Auth0KeycloakAmazon Cognito
운영 형태SaaSSelf-hosted (오픈소스)AWS 관리형
PKCE 기본 지원기본 활성화2.1+ 기본 활성화기본 지원
Refresh Token Rotation기본 지원지원지원
DPoP 지원Enterprise 플랜 이상22+ 버전 실험적미지원
무료 한도월 7,500 MAU무제한월 50,000 MAU
커스터마이징제한적 (Actions)매우 유연제한적

선택 기준 요약: 팀이 인프라를 직접 관리하기 어렵고 빠르게 시작해야 한다면 Auth0, AWS 생태계 중심으로 운영하고 비용 효율이 중요하다면 Cognito, 금융·의료처럼 규제가 강하거나 완전한 데이터 소유권이 필요하다면 Keycloak 자체 호스팅이 현실적입니다.

실무에서 자주 간과되는 기준은 '마이그레이션 비용'입니다. Auth0나 Cognito 같은 SaaS형 IdP는 초기 도입이 빠르지만, 사용자 데이터베이스와 커스텀 로그인 UI 로직이 벤더의 확장 포인트(Auth0 Actions, Cognito Lambda Trigger)에 강하게 결합되기 쉽습니다. 이후 조직 규모가 커져 자체 Keycloak 클러스터로 전환하려 할 때는 단순히 클라이언트 등록 정보만 옮기면 되는 것이 아니라, 사용자 비밀번호 해시 알고리즘 호환성과 MFA 등록 정보 이관, 세션 무효화 정책까지 함께 재설계해야 합니다. 팀 규모가 작고 빠른 출시가 우선이라면 SaaS로 시작하되, 처음부터 OIDC Discovery(/.well-known/openid-configuration) 표준 엔드포인트에만 의존하는 클라이언트 코드를 작성해 두면 이후 벤더 교체 시 애플리케이션 코드 변경을 최소화할 수 있습니다.


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');
}

실무에서 흔히 마주치는 함정은 여러 탭에서 동시에 로그인을 시도하는 경우입니다. 사용자가 로그인 페이지를 새 탭에서 열어둔 채 원래 탭에서도 로그인 버튼을 누르면, sessionStorage에 저장된 oauth_state 값이 나중 요청으로 덮어써집니다. 그 결과 먼저 열었던 탭의 콜백이 반환됐을 때 저장된 state와 불일치가 발생해 OAuth state mismatch 예외가 던져집니다. 이는 공격이 아니라 정상 사용자의 UX 실수이므로, 에러 메시지를 "보안 경고"로 노출하기보다 "로그인을 다시 시도해 주세요" 수준의 부드러운 안내로 처리하는 것이 좋습니다. 또한 OIDC를 함께 사용한다면 state와 별개로 ID Token 재전송 공격을 막는 nonce 파라미터도 authorization 요청에 포함해야 합니다. state는 요청의 출처(CSRF)를, nonce는 발급된 토큰 자체의 재사용(replay)을 각각 방어하는 서로 다른 목적의 값이라는 점을 혼동하지 않아야 합니다.


5. token_endpoint_auth_method 설정과 클라이언트 인증

SPA와 네이티브 모바일 앱은 "퍼블릭 클라이언트"입니다. client_secret을 안전하게 보관할 수 없기 때문에 token endpoint에서 클라이언트 인증을 할 수 없습니다.

PKCE를 사용하는 퍼블릭 클라이언트의 올바른 등록 설정:

  • client_type: public
  • token_endpoint_auth_method: none
  • grant_types: authorization_code, refresh_token
  • response_types: code
  • pkce_required: true

모바일 네이티브 앱에서는 여기에 한 가지 기준이 더 추가됩니다. Redirect URI로 커스텀 URL 스킴(myapp://callback)을 등록하면, 같은 스킴을 등록한 다른 앱이 authorization code를 가로챌 여지가 생깁니다. iOS의 Universal Links, Android의 App Links처럼 운영체제가 도메인 소유권을 검증하는 HTTPS 기반 딥링크를 사용하면 훨씬 안전합니다. 또한 로그인 화면을 앱 내장 WebView로 띄우는 것은 피해야 합니다. WebView는 호스트 앱 코드가 페이지의 쿠키와 세션을 들여다볼 수 있고, 사용자 입장에서도 실제 인가 서버 도메인에 접속했는지 확인할 방법이 없습니다. iOS는 ASWebAuthenticationSession, Android는 Chrome Custom Tabs를 사용해 시스템 브라우저의 로그인 세션을 공유하면서도 앱 코드와 격리된 환경에서 인가 절차를 진행해야 합니다.

서버사이드 컴포넌트(BFF, 백엔드 API)는 기밀 클라이언트입니다. 이 경우 client_secret_basic(Basic Auth)이나 private_key_jwt(JWT assertion)를 사용할 수 있습니다.


본문 중간 핵심 흐름을 시각화한 Security 일러스트

6. 액세스 토큰 저장 위치 비교

저장 위치XSS 노출CSRF 노출권장 사용
localStorage높음없음사용 금지 권고
sessionStorage높음없음단기 임시 보관
메모리 (JS 변수)낮음없음Access token 권장
HttpOnly Cookie없음있음Refresh token 권장

현재 업계 권장 전략은 다음과 같습니다. Access token은 메모리(JavaScript 변수)에만 보관합니다. Refresh token은 HttpOnly Cookie에 보관하고, CSRF 방어를 함께 적용합니다.

localStorage에 access token을 저장하는 것은 2026년 현재 공개적으로 사용 금지가 권고됩니다. 메모리 저장 방식의 단점도 분명히 인지해야 합니다. 사용자가 브라우저를 새로고침하면 메모리에 있던 access token은 사라지므로, 페이지 로드 시점마다 HttpOnly Cookie에 담긴 refresh token으로 조용히 access token을 재발급받는 흐름(silent bootstrap)이 함께 필요합니다. 이 흐름이 없으면 새로고침할 때마다 사용자가 로그인 화면을 다시 보게 되는 심각한 UX 저하로 이어집니다. 더 자세한 쿠키·세션 보안 설계는 웹 인증 세션 보안에서 다룹니다.


7. Silent Renewal vs iframe vs Refresh Token Rotation

Silent Renewal (iframe 방식): 숨겨진 iframe을 생성해 인가 서버의 /authorize 엔드포인트를 호출합니다. 현재 이 방식은 Chrome의 서드파티 쿠키 차단 정책과 ITP 때문에 점점 신뢰성을 잃고 있습니다.

Refresh Token Rotation: Refresh token으로 새 access token을 요청할 때마다 새 refresh token도 함께 발급하고 기존 refresh token을 즉시 무효화하는 방식입니다. oauth.net/2/pkce에서도 이 방식을 SPA의 기본 갱신 전략으로 권장합니다.

현재 권장하는 조합은 Refresh Token Rotation + HttpOnly Cookie 저장 + PKCE입니다.

Rotation을 실제 운영에 투입하면 반드시 마주치는 문제가 동시 요청 경쟁 상태입니다. 사용자가 같은 애플리케이션을 여러 탭이나 여러 기기에서 동시에 열어두면, 두 개의 요청이 거의 동시에 같은 refresh token으로 갱신을 시도할 수 있습니다. Rotation 정책상 먼저 도착한 요청만 성공하고 두 번째 요청은 "이미 사용된 토큰"으로 거부되어, 재사용 탐지 로직이 정상 사용자를 탈취 의심 세션으로 잘못 판단해 강제 로그아웃시킬 수 있습니다. 이를 막기 위해 흔히 두 가지 방법을 씁니다. 하나는 클라이언트에서 refresh 요청을 하나의 Promise로 묶어 중복 호출 자체를 차단하는 것이고, 다른 하나는 서버 측에서 아주 짧은 grace window(예: 수 초) 동안은 직전 세대의 refresh token 재사용을 탈취가 아닌 정상 경쟁으로 간주해 허용하는 것입니다. 어느 쪽이든 '탐지 정확도'와 '정상 사용자 경험' 사이의 트레이드오프이므로, 서비스의 위협 모델에 맞게 grace window 길이를 조정해야 합니다.


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 알고리즘 공격에서 별도로 다룹니다.

2026년 현재 DPoP의 실무 도입 속도는 빠르지 않습니다. 사양 자체는 확정됐지만, 클라이언트가 매 요청마다 키 쌍을 관리하고 서명을 생성해야 하므로 구현 복잡도가 PKCE보다 훨씬 높고, CDN이나 API 게이트웨이 계층에서 DPoP 증명을 검증하려면 별도의 미들웨어가 필요합니다. 따라서 일반적인 커머스나 SaaS 서비스라면 Refresh Token Rotation과 짧은 access token 수명만으로도 충분한 경우가 많고, DPoP은 규제 요건이 명시적으로 토큰 바인딩을 요구하는 금융·공공 부문에 한해 우선적으로 검토하는 것이 현실적인 우선순위입니다.


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로 활용하면 별도 서비스 없이 같은 저장소에서 관리할 수 있어 현실적인 절충점이 됩니다.

다만 BFF 패턴을 실제 트래픽 규모에서 운영하려면 세션 저장 방식을 신중히 설계해야 합니다. 위 예시처럼 express-session의 기본 메모리 스토어를 그대로 쓰면 인스턴스가 여러 대로 스케일 아웃되는 순간 세션 어피니티(sticky session) 없이는 로그인이 유지되지 않습니다. 실무에서는 Redis 같은 외부 세션 스토어를 연결하고, access token과 refresh token을 세션 객체와 분리된 암호화 캐시에 저장해 세션 조회 시 민감한 토큰 값이 로그로 노출될 여지를 줄이는 편이 안전합니다. 또한 BFF가 쿠키 기반 세션을 쓰는 이상 CSRF 위협에서 자유롭지 않으므로, SameSite=Lax 이상의 쿠키 정책과 상태 변경 요청에 대한 CSRF 토큰 검증을 별도로 갖춰야 합니다. 이 부분의 구체적인 쿠키 설계는 웹 인증 세션 보안에서 더 깊게 다룹니다.


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',
  });
}

11. BFF 패턴 vs 브라우저 저장 SPA: 아키텍처 선택 기준 심화

앞서 소개한 두 구현, 즉 BFF 패턴과 oidc-client-ts 기반의 순수 SPA 패턴은 둘 다 PKCE를 올바르게 사용하지만 보안·운영 특성이 다릅니다. 어떤 조직이든 이 둘 중 하나를 고정된 정답으로 여기기보다, 다음 기준으로 판단해야 합니다.

판단 기준BFF 패턴순수 SPA + oidc-client-ts
Access token 노출면브라우저에 전혀 노출되지 않음메모리에 존재, XSS 시 유출 위험
추가 인프라전용 백엔드 서비스 필요불필요, 정적 호스팅만으로 가능
세션 확장성세션 저장소(Redis 등) 운영 부담인가 서버가 세션 상태를 담당
모바일 앱 재사용성낮음(웹 전용 설계)높음(네이티브 SDK와 흐름 유사)
CORS·쿠키 설계 복잡도SameSite, 도메인 정책 신경 필요상대적으로 단순(Bearer 토큰)
지연 시간API 호출마다 BFF 홉 추가클라이언트가 인가 서버·API에 직접 접근

필자의 경험으로는, 결제·금융처럼 XSS 한 번의 사고도 감당할 수 없는 서비스는 초기 구축 비용을 감수하더라도 BFF 패턴을 기본값으로 삼는 것이 맞습니다. 반면 마케팅 성격의 대시보드나 내부 도구처럼 위협 모델이 상대적으로 낮고, 같은 인증 흐름을 모바일 앱과 공유해야 하는 제품은 순수 SPA 패턴에 CSP와 의존성 관리 같은 XSS 방어를 두텁게 쌓는 편이 조직의 실제 역량에 더 맞습니다. 중요한 것은 "더 안전해 보이는 쪽"이 아니라, 그 패턴을 지속적으로 운영할 수 있는 팀 역량과 인프라 예산입니다. BFF를 도입했는데 세션 저장소 장애 대응 체계가 없다면, 그 BFF는 오히려 새로운 단일 장애점이 됩니다.


12. 운영과 마이그레이션: Implicit에서 PKCE로 단계적으로 전환하기

이미 Implicit Flow로 운영 중인 서비스를 PKCE 기반 Authorization Code Flow로 전면 교체하는 작업은 하루아침에 끝나지 않습니다. 필자가 겪은 마이그레이션은 다음과 같은 순서로 진행됐습니다.

먼저 인가 서버 설정에서 기존 클라이언트를 건드리지 않은 채, response_type=code와 PKCE를 사용하는 신규 클라이언트를 별도로 등록했습니다. 기존 Implicit 클라이언트와 신규 PKCE 클라이언트를 동시에 운영하는 기간을 뒀고, 프런트엔드에는 기능 플래그를 심어 트래픽의 일부(처음에는 5%)만 새 흐름으로 리다이렉트했습니다.

이 단계에서 가장 중요한 것은 관측 지표입니다. 인가 콜백 실패율, invalid_grant 발생 빈도, redirect_uri mismatch 오류, 그리고 토큰 발급까지 걸리는 지연 시간을 새 흐름과 기존 흐름 각각 대시보드로 분리해 비교했습니다. 새 흐름에서 실패율이 기존 대비 눈에 띄게 높다면, 대부분 원인은 code_verifier 저장소 유실이나 redirect_uri 등록값의 트레일링 슬래시 불일치처럼 사소한 설정 문제였습니다.

트래픽 비중을 5%에서 25%, 50%, 100%로 단계적으로 올리면서 동시에 기존 Implicit 클라이언트의 신규 로그인은 막고, 이미 발급된 세션은 자연 만료를 기다리는 방식으로 걷어냈습니다. 모든 사용자를 즉시 강제 로그아웃시키는 방법도 있지만, 이는 사용자 경험 저하와 고객센터 문의 폭증으로 이어지므로 트래픽이 큰 서비스에서는 권장하지 않습니다. 마지막으로 인가 서버에서 Implicit 클라이언트의 response_type=token 자체를 비활성화하고, 이 설정 변경을 최소 한 번의 배포 주기 이상 모니터링한 뒤에야 완전히 마이그레이션이 끝났다고 판단했습니다.

이 과정에서 배운 원칙은 단순합니다. 인증 흐름 마이그레이션은 기능 배포가 아니라 인프라 마이그레이션처럼 다뤄야 합니다. 롤백 경로를 항상 열어두고, 지표로 판단하고, 사용자에게 미리 공지해야 합니다.


13. 실무자가 자주 묻는 질문

Q. PKCE를 쓰면 client_secret이 아예 필요 없나요?
네, SPA와 네이티브 앱처럼 token_endpoint_auth_method: none으로 등록된 퍼블릭 클라이언트는 client_secret 없이 PKCE만으로 인증 코드 교환의 안전성을 확보합니다. 다만 서버사이드 웹 애플리케이션처럼 client_secret을 안전하게 보관할 수 있는 기밀 클라이언트라면, PKCE를 추가로 적용하더라도 client_secret 기반 인증(client_secret_basic 등)을 함께 유지하는 것이 방어를 이중화하는 관점에서 유리합니다. PKCE와 client_secret은 대체 관계가 아니라 상호 보완 관계입니다.

Q. 모바일 앱에서 refresh token을 Keychain(iOS)이나 Keystore(Android)에 저장해도 되나요?
네, 오히려 권장됩니다. 브라우저 환경과 달리 iOS Keychain과 Android Keystore는 OS 수준의 암호화와 앱 샌드박스 격리를 제공하므로 다른 앱이나 프로세스가 접근하기 매우 어렵습니다. 이는 "브라우저에서는 HttpOnly Cookie, 모바일에서는 OS 보안 저장소"라는 플랫폼별 최선의 저장 전략이 서로 다르다는 것을 보여줍니다. 웹의 저장 전략을 모바일에 그대로 옮기려 하지 않아야 합니다.

Q. code_challenge_method=plain을 여전히 지원해야 하나요?
신규 구축이라면 지원하지 않는 것이 맞습니다. plain은 code_verifier를 해시 없이 그대로 전송하기 때문에 code_challenge 값 자체가 verifier와 동일합니다. 네트워크 구간이 완전히 신뢰할 수 없다면 그만큼의 위협에 그대로 노출됩니다. 레거시 클라이언트 호환을 위해 인가 서버가 plain을 남겨두는 경우는 있지만, 신규 클라이언트 등록 시에는 옵션 자체를 비활성화해야 합니다.

Q. OAuth 2.1이 아직 초안 단계인데, 지금 당장 대응해야 하나요?
정식 RFC 번호를 기다릴 필요는 없습니다. OAuth 2.1 초안이 요구하는 변경사항인 PKCE 필수화, Implicit·ROPC 제거, 정확한 redirect_uri 매칭은 이미 RFC 9700(OAuth Security BCP)을 통해 사실상 확정된 모범 사례입니다. 이 글에서 다루는 원칙을 따르고 있다면 OAuth 2.1이 정식 승인되더라도 추가로 크게 바꿀 부분은 없을 것입니다.

Q. Access token의 만료 시간은 얼마로 설정해야 하나요?
정답은 위협 모델에 따라 다르지만, 실무에서는 5~15분 사이로 짧게 가져가고 만료 시 refresh token으로 조용히 갱신하는 방식이 보편적입니다. 만료 시간을 지나치게 길게(수 시간 이상) 설정하면 토큰 탈취 시 피해 창(window)이 늘어나고, 지나치게 짧게(1분 미만) 설정하면 refresh 요청 빈도가 늘어 인가 서버 부하와 네트워크 비용이 증가합니다.


정리

OAuth 2.0 PKCE 흐름은 Implicit의 위험을 제거하면서도 SPA와 모바일 앱에서 실용적으로 적용 가능한 표준이 됐습니다.

  • PKCE S256 강제 적용: 인가 서버에서 퍼블릭 클라이언트에 대해 pkce_required=truecode_challenge_method=S256을 강제했는가.
  • state 검증 누락 없음: 인가 콜백에서 반드시 state 파라미터를 저장된 값과 비교합니다.
  • Access token 저장 위치: localStorage에 access token을 보관하지 않습니다.
  • Refresh Token Rotation 활성화: 인가 서버에서 refresh token rotation을 활성화합니다.
  • token_endpoint_auth_method: none 명시: SPA·모바일 클라이언트 등록 시 퍼블릭 클라이언트로 명시합니다.