OAuth PKCE 구현 회고: React·Flutter 인증을 3개월간 다시 짠 기록

목차

첫날 부딪힌 에러부터

[req] POST https://idp.example.com/oauth/token
[req] Content-Type: application/x-www-form-urlencoded
[req] grant_type=authorization_code&code=ZX...&code_verifier=q3F...
[res] HTTP/1.1 400 Bad Request
{
  "error": "invalid_grant",
  "error_description": "code_verifier does not match code_challenge"
}

따라서, OAuth PKCE 구현 첫날에 보고 두 시간을 날린 응답이다. React에서 code_verifier를 sessionStorage에 넣어두고 콜백에서 꺼내 토큰 요청에 실어 보냈는데, 인가 서버가 매칭에 실패했다. 원인은 단순했다. code_challenge를 만들 때 SHA-256 결과를 일반 base64로 인코딩했는데, URL-safe 변형(+-, /_, = 제거)을 빠뜨린 것이다. RFC 7636 4.2절은 base64url을 요구한다(출처: RFC 7636). 일반 base64로 보내면 인가 서버가 SHA-256 비교 단계에서 다른 문자열로 인식하고 그대로 깨진다.

결국, 이 글은 그 첫날 이후 3개월 동안 React SPA와 Flutter 모바일에서 Authorization Code Flow + PKCE를 어떻게 다시 짰는지에 대한 회고다. 프론트엔드를 5년 만지다 백엔드로 옮긴 지 2년이 됐는데, 인증은 두 영역이 가장 진하게 맞물리는 자리라 양쪽 시각이 다 필요했다.

시작점: 왜 Implicit Flow를 버렸는가

기존 시스템은 SPA용으로 Implicit Flow를 썼다. 인가 서버가 access_token을 URL fragment(#access_token=...)로 바로 던져주는 방식이다. 한때 SPA의 표준처럼 쓰였는데 OAuth 2.0 Security Best Current Practice (RFC 9700)에서 명시적으로 비권장으로 바뀌었다(2025-01 공표). 토큰이 브라우저 히스토리, Referer 헤더, 서버 로그에 노출될 위험이 있다는 게 이유였다.

그러나, 회사 보안팀에서 분기 점검 때 이 점을 지적해 왔다. 마이그레이션이 강제 항목으로 잡혔고, 모바일 앱(Flutter) 쪽도 같이 손보기로 했다. 모바일은 시스템 브라우저로 띄워 콜백을 받는 구조였는데, custom URL scheme(myapp://) 가로채기 공격을 막으려면 PKCE가 사실상 필수였다.

후보군과 첫인상

라이브러리는 양쪽 플랫폼에서 검토했다. 단순 비교가 필요한 지점이라 표로 정리했다.

라이브러리 플랫폼 버전 (2026-03 기준) 첫인상
oidc-client-ts React/JS 3.1.0 OIDC 표준을 잘 따른다, 번들이 무겁다
@auth0/auth0-react React 2.2.4 Auth0 종속, 자체 IdP라 제외
직접 구현 React 200줄이면 가능, silent refresh가 부담
flutter_appauth Flutter 6.0.2 OpenID Foundation 기반, 안드·iOS 차이 흡수
flutter_web_auth_2 Flutter 3.1.2 가볍지만 PKCE는 직접 구현

oidc-client-ts와 flutter_appauth 조합으로 갔다. 자체 IdP라 Auth0 SDK는 제외했고, React 쪽은 직접 구현도 고려했는데 silent refresh와 토큰 저장 정책까지 직접 짤 여유가 없었다. 백엔드로 옮긴 뒤 깨달은 건데, 인증 라이브러리에 시간을 쓰면 비즈니스 로직에 쓸 시간이 사라진다. 처음부터 표준 라이브러리에 위임하는 게 맞다.

Authorization Code + PKCE 흐름 한 번 짚기

반면, 흐름 자체는 단순하다. 매 단계마다 빠지기 쉬운 함정이 하나씩 있을 뿐이다.

[클라이언트]                                [인가 서버]
  ├ code_verifier 생성 (랜덤 43~128자, A-Z a-z 0-9 - . _ ~)
  ├ code_challenge = base64url(SHA256(verifier))
  │
  ├─ GET /authorize?response_type=code
  │   &client_id=...&redirect_uri=...
  │   &code_challenge=...&code_challenge_method=S256
  │   &state=<csrf-token>&scope=openid offline_access  →
  │                                          (사용자 로그인/동의)
  │                                        ← 302 redirect_uri?code=xxx&state=...
  │
  ├ state 검증
  ├─ POST /token
  │   grant_type=authorization_code
  │   &code=xxx&code_verifier=...           →
  │                                          (verifier hash와 challenge 비교)
  │                                        ← {access_token, refresh_token, id_token}

여기서 code_verifier클라이언트가 직접 보관하는 비밀이다. sessionStorage, secure storage, 인메모리 등 어디에 둘지가 보안 모델의 출발점이다. 모바일에서는 OS의 keystore에 두면 깔끔한데, SPA에는 마땅한 곳이 없다. 이 점이 한 달 뒤 다시 발목을 잡았다.

state는 PKCE와는 다른 레이어다. PKCE는 인가 코드 가로채기를 막고, state는 콜백이 우리가 시작한 흐름의 응답인지를 검증한다. 둘은 보완 관계지 대체 관계가 아니다.

React SPA에 PKCE 적용하기

반면, oidc-client-ts 3.1.0 기준으로 정리한 설정이다. 공식 README 기본 예제보다 실무에 가까운 옵션을 몇 개 추가했다.

// src/auth/userManager.ts
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';

export const userManager = new UserManager({
  authority: 'https://idp.example.com',
  client_id: 'spa-client-prod',
  redirect_uri: `${window.location.origin}/callback`,
  post_logout_redirect_uri: `${window.location.origin}/`,
  response_type: 'code',                // Authorization Code Flow
  scope: 'openid profile email offline_access',

  // 라이브러리가 code_verifier/challenge를 자동 생성하고 검증한다
  // S256이 디폴트 (challenge_method)

  // 세션 상태 저장 — 탭 단위 격리
  userStore: new WebStorageStateStore({ store: window.sessionStorage }),

  // silent refresh
  automaticSilentRenew: true,
  silent_redirect_uri: `${window.location.origin}/silent-callback.html`,

  // access_token 만료 60초 전에 미리 갱신
  accessTokenExpiringNotificationTimeInSeconds: 60,

  // 로드 시 사용자 정보 자동 보강
  loadUserInfo: true,
});

물론, 콜백 페이지는 의외로 단순하다.

// src/pages/Callback.tsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { userManager } from '../auth/userManager';

export default function Callback() {
  const navigate = useNavigate();

  useEffect(() => {
    userManager.signinRedirectCallback()
      .then((user) => {
        // 인가 서버가 돌려준 state로 원래 가려던 페이지 복원
        const returnTo = (user.state as string | undefined) ?? '/';
        navigate(returnTo, { replace: true });
      })
      .catch((err) => {
        console.error('signin callback failed', err);
        navigate('/login?error=callback', { replace: true });
      });
  }, [navigate]);

  return <div>로그인 처리 중...</div>;
}

토큰을 어디에 둘지가 가장 어렵다

라이브러리 설정은 사실 쉬운 부분이다. 머리 아픈 건 토큰 저장소다. 세 가지 선택지를 두고 한참 토론했다.

  • localStorage: XSS 한 방에 다 털린다. 비추천.
  • sessionStorage: 탭 단위 격리, 새로고침엔 살아남는다. XSS엔 여전히 취약.
  • 메모리(JS 변수): 가장 안전, 새로고침하면 사라진다. silent refresh로 복구.

결국 access_token은 메모리, refresh_token은 HttpOnly + Secure + SameSite=Lax 쿠키 + BFF(Backend for Frontend) 패턴으로 갔다. 백엔드 게이트웨이를 하나 두고, SPA는 쿠키만 들고 다니는 구조다. 프론트엔드 코드만 만지던 시절에는 BFF가 과해 보였는데 백엔드로 옮기고 보니 이게 가장 깔끔하다. 토큰을 브라우저에 노출하지 않는 게 시작점이라는 사실이 명확하게 보였다.

SPA에서 refresh_token을 JS로 접근 가능한 저장소(localStorage, sessionStorage, IndexedDB)에 두는 건 권장되지 않는다. OAuth 2.0 BCP(RFC 9700) 6.3.3절은 refresh token이 sender-constrained하지 않다면 SPA 컨텍스트에서는 사용을 피할 것을 명시한다. BFF가 어렵다면 access_token 수명을 짧게(5~15분) 두고 silent refresh로 갱신하는 쪽이 차선이다.

Flutter에서 같은 흐름 처리하기

모바일은 SPA와 다르다. 가장 큰 차이는 redirect_uri의 형태다. 웹은 https://app.example.com/callback이지만 모바일은 com.example.myapp://callback 같은 custom URL scheme 또는 universal/app links를 쓴다.

// lib/auth/oauth_service.dart
import 'package:flutter_appauth/flutter_appauth.dart';

class OAuthService {
  final FlutterAppAuth _appAuth = const FlutterAppAuth();

  static const String _clientId = 'mobile-client-prod';
  static const String _redirectUri = 'com.example.myapp://callback';
  static const String _issuer = 'https://idp.example.com';

  Future<AuthorizationTokenResponse?> signIn() async {
    try {
      final result = await _appAuth.authorizeAndExchangeCode(
        AuthorizationTokenRequest(
          _clientId,
          _redirectUri,
          issuer: _issuer,
          scopes: ['openid', 'profile', 'email', 'offline_access'],
          // PKCE는 flutter_appauth가 기본으로 켠다 (S256)
          preferEphemeralSession: true, // iOS에서 SSO 쿠키 격리
        ),
      );
      return result;
    } catch (e, st) {
      // PlatformException: AuthorizationFailed 형태로 자주 떨어진다
      print('auth failed: $e\n$st');
      return null;
    }
  }
}

Android에서는 AndroidManifest.xml에 인텐트 필터를 추가한다.

<activity android:name="net.openid.appauth.RedirectUriReceiverActivity"
    android:exported="true">
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="com.example.myapp" />
  </intent-filter>
</activity>

iOS는 Info.plistCFBundleURLTypes에 동일 스킴을 등록한다. 여기까진 공식 문서 그대로다. 함정은 토큰 저장 쪽에 있었다.

Keystore/Keychain 한 줄이 비쌌다

즉, 처음에는 SharedPreferences(Android), UserDefaults(iOS)에 refresh_token을 그대로 넣었다. 동작은 한다. 문제는 루팅·탈옥 단말에서 그대로 노출된다는 점이다. 보안팀 1차 리뷰에서 바로 걸렸다.

flutter_secure_storage 9.0.0으로 바꿨다. Android는 Keystore + EncryptedSharedPreferences, iOS는 Keychain Services를 자동으로 쓴다.

final storage = const FlutterSecureStorage(
  aOptions: AndroidOptions(
    encryptedSharedPreferences: true,
  ),
  iOptions: IOSOptions(
    accessibility: KeychainAccessibility.first_unlock_this_device,
  ),
);

await storage.write(key: 'refresh_token', value: token);
final token = await storage.read(key: 'refresh_token');

이처럼, iOS에서 accessibility 옵션을 빠뜨리면 디폴트가 unlocked로 잡혀, 백그라운드 토큰 갱신 시 실패한다. 이걸 모르고 한나절을 헤맸다. 정확히는 첫 잠금 해제 이후엔 디바이스가 잠겨도 키체인 접근이 되어야 백그라운드 패치가 동작하는데, 디폴트 옵션은 디바이스가 잠기면 접근을 거부한다.

Custom Scheme 대신 Universal Links로 일부 갈아탔다

특히, iOS에서 com.example.myapp:// 스킴은 다른 앱이 같은 스킴을 등록하면 가로챌 수 있다. Universal Links(iOS)/App Links(Android)로 가면 도메인 검증이 더해져 가로채기를 막을 수 있다. 다만 도메인 인증 파일(apple-app-site-association, assetlinks.json)을 HTTPS로 서빙해야 하고, 인증서 CN과 정확히 매칭되어야 동작한다는 운영 부담이 있다.

결국 Sign-in 흐름만 Universal Links로 옮기고, 인앱 딥링크는 custom scheme을 유지했다. 전부 옮기려다 도메인 설정 검증에 일주일을 더 쓰는 건 ROI가 맞지 않았다.

프로젝트 중간에 터진 것들

또한, 3개월 중 중반 한 달이 가장 험했다. 구현은 끝났는데 QA에서 이슈가 쏟아졌다.

redirect_uri 미스매치의 다섯 변종

error=invalid_request로 떨어지는 케이스가 환경마다 달랐다.

  • 개발: http://localhost:3000/callback — IdP에 등록 안 됨
  • 스테이징: https://staging.example.com/callback/ — 끝 슬래시 차이
  • 프로덕션 iOS: com.example.myapp://callback vs com.example.myapp:/callback — 슬래시 개수
  • 프로덕션 Android: 동일 스킴인데 대소문자가 다름
  • PR 프리뷰: 매번 URL이 바뀌어 등록 불가능

PR 프리뷰는 결국 와일드카드 redirect를 허용하지 않는 IdP 정책을 우회하지 못해, 프리뷰 전용 별도 클라이언트를 따로 두는 걸로 갔다. 깔끔한 해법은 아니지만 정책상 다른 길이 없었다.

state 파라미터를 빠뜨려서 받은 경고

PKCE만 있으면 끝나는 줄 알았다. 그게 아니었다. CSRF 방어는 state 파라미터가 담당한다. 이미 짚었지만 한 번 더 강조한다. PKCE는 인가 코드 가로채기 방어, state는 콜백 응답의 무결성 검증이다. 서로 다른 위협 모델을 막는다.

물론, oidc-client-ts는 state를 자동으로 생성·검증한다. flutter_appauth도 마찬가지다. 직접 구현하면 빠뜨리기 쉬운 지점이라 라이브러리를 쓰는 이유가 여기에 하나 더 추가됐다. 보안팀 리뷰 체크리스트에 "state 검증 로그가 남는가"가 항목으로 있었는데, 라이브러리에 위임한 상태라 별도 디버그 로그를 일부러 켜놓고 증빙을 남겨야 했다.

silent refresh가 무한 루프를 돌았다

iframe 기반 silent refresh를 도입하고 일주일 뒤, Sentry에 같은 사용자 1명이 분당 200건 토큰 요청을 보내는 게 잡혔다. 원인은 silent-callback.html이 부모 프레임에 메시지를 전달하지 못한 채로 끝나서, 부모가 timeout 후 다시 시도하는 루프였다. iframe sandbox 속성과 CSP frame-ancestors가 충돌하고 있었다.

물론, 부모 origin과 동일하게 CSP를 풀고, silent-callback.html에 명시적으로 parent.postMessage를 호출하게 고쳤다. 라이브러리 디폴트 동작에 너무 기댄 결과였다. 인증 흐름은 보안 헤더와 정면으로 부딪히는 영역이라, CSP를 새로 도입할 때는 반드시 silent refresh가 통과하는지 별도 회귀 테스트가 필요하다.

Refresh Token Rotation을 붙이고 나서

마지막 한 달은 refresh token rotation을 붙였다. 토큰을 한 번 쓰면 새 refresh_token으로 교체되고 이전 것은 즉시 무효화되는 방식이다. RFC 6819 5.2.2.3에 권고로 들어가 있다.

반면, IdP가 rotation을 지원하니 클라이언트는 옵션만 켜면 될 줄 알았다. 실제로는 동시성 문제가 튀어나왔다. 모바일 앱이 백그라운드 작업 두 곳에서 동시에 토큰 갱신을 시도하면, 둘 중 하나가 이미 무효화된 refresh_token으로 시도해 사용자가 갑자기 로그아웃되는 현상이 생겼다. Sentry에는 invalid_grant가 새벽 시간대에 갑자기 튀는 패턴으로 잡혔다.

물론, 해법은 단일 갱신 채널이다. 갱신 요청을 mutex로 묶고, 다른 호출자는 진행 중인 Future를 await하게 했다.

class TokenRefresher {
  Future<String>? _ongoing;

  Future<String> refresh() {
    if (_ongoing != null) return _ongoing!;
    _ongoing = _doRefresh().whenComplete(() => _ongoing = null);
    return _ongoing!;
  }

  Future<String> _doRefresh() async {
    // 실제 IdP 호출 — 한 번만 실행됨
  }
}

물론, React에서는 같은 패턴을 Promise로 구현했다. 진행 중인 Promise를 모듈 스코프에 보관하고 새 호출자는 그걸 그대로 받게 한다. 인증 코드는 동시성 버그가 가장 많이 나오는 영역이라는 걸 이때 체감했다. 단일 사용자가 멀티탭을 열거나, 앱이 백그라운드에서 푸시 응답을 처리할 때 자연스럽게 동시 호출이 생긴다.

언제 PKCE를 쓰고 언제 다른 길로 가나

3개월 끝나고 다시 돌아보면 판단 기준은 이렇다.

  • 퍼블릭 클라이언트(SPA, 모바일, 데스크톱 앱)라면 Authorization Code + PKCE가 기본이다. 2026년 기준 OAuth 2.1 초안과 BCP가 일관되게 권고한다.
  • 서버 사이드 웹앱(전통적 MPA)이라면 Authorization Code 단독으로도 충분하다. client_secret을 서버가 보관할 수 있다면 PKCE는 추가 레이어 정도다. 켜놔서 손해 보는 일은 없다.
  • 머신 투 머신(서비스 간 호출)이라면 Client Credentials Grant가 맞다. PKCE를 끼울 자리가 아니다.
  • 레거시 IdP가 PKCE를 지원하지 않는 상황이라면, IdP 자체를 교체하는 게 장기적으로 싸다. 2026년에 PKCE 미지원 IdP는 운영 위험이 크다고 본다.
  • SPA에서 BFF를 못 두는 상황이라면, refresh token rotation + 짧은 access_token 수명(10~15분) + 메모리 저장이 차선이다. 영구 저장은 피해라.

반면, 당장 실행할 수 있는 액션 세 개로 좁히면 이렇다.

  1. 코드베이스에서 response_type=token이나 response_type=id_token token을 grep으로 찾아라. 잡힌다면 마이그레이션 대상이다.
  2. SPA의 토큰 저장소가 localStorage라면 sessionStorage 또는 메모리로 옮기고, refresh_token은 BFF로 빼는 PoC를 한 번 잡아라.
  3. 모바일이라면 flutter_secure_storage(또는 네이티브의 EncryptedSharedPreferences/Keychain)를 쓰고 있는지, iOS accessibility 옵션이 백그라운드 갱신에 맞게 잡혔는지 확인하라.

한편, 가장 비용이 컸던 건 코드 자체가 아니라 토큰 저장소 결정과 동시성 처리였다. PKCE 알고리즘 구현은 라이브러리가 해주고, 실제 시간은 그 주변에서 다 쓰였다. 처음부터 BFF를 깔고 시작했다면 한 달은 아꼈을 것으로 보인다.

관련 글