별도 도메인에 새 서비스를 띄우고, 기존 서비스의 계정으로 로그인시켜야 했습니다. 처음 받아 든 코드에는 "기존 서비스가 발급한 쿠키를 읽는다"고 적혀 있었습니다. 그 전제는 한 줄도 동작한 적이 없었습니다.
이 글은 그 자리를 OAuth 2.0 authorization code + PKCE로 다시 세운 기록입니다. 설계보다 무너진 지점에 무게를 뒀습니다. 다섯 번 틀렸고, 그중 두 번은 테스트가 초록불인 채로 운영에서 터졌습니다.
id_token·JWKS·discovery)는 만들지 않았습니다.DB 커밋과 이벤트 발행이 따로 노는 dual-write 문제를 아웃박스 테이블과 지수 백오프 재시도, 데드레터 격리로 막은 실제 NestJS 구현 회고.
새 서비스는 별도 등록 도메인을 씁니다. 기존 서비스와는 최상위 도메인이 다릅니다. 그런데 인증 게이트는 이렇게 생겨 있었습니다.
typescript// 미들웨어 — 보호 경로 진입 검사 const hasAuthToken = Boolean(request.cookies.get(AUTH_COOKIE_NAME)?.value); if (hasAuthToken) return NextResponse.next(); return NextResponse.redirect(LOGIN_URL);
문제가 두 겹입니다.
하나, 값을 검증하지 않습니다. 이름이 같은 쿠키가 있기만 하면 통과합니다. 방문자가 개발자 도구에서 document.cookie = 'auth_token=x' 한 줄이면 보호 영역 전체가 열립니다.
둘, 그 쿠키는 이 도메인에 실릴 수 없습니다. 기존 서비스가 자기 도메인에 발급한 쿠키는 다른 등록 도메인의 요청에 붙지 않습니다. 즉 이 분기는 정상적으로 참이 될 경로가 없고, 오직 우회로로만 기능했습니다.
실제 입구는 따로 있었습니다. 전체 사용자가 공유하는 단일 토큰을 쿠키로 심는 임시 로그인이었습니다. 신원이 없으니 "누가 주문했는지"를 서비스가 알 수 없었습니다.
여기서 배운 첫 번째 교훈은 단순합니다. "쿠키를 공유하면 되지 않나"는 도메인을 먼저 확인하고 말해야 합니다. 서드파티 쿠키 차단이라는 최신 이슈를 꺼내기 전에, 같은 사이트인지부터 봐야 합니다.
별도 도메인 사이에서 신원을 넘기는 방법은 결국 최상위 리다이렉트뿐입니다. 숨겨진 iframe으로 조용히 세션을 확인하던 예전 방식은 서드파티 쿠키가 막히면서 함께 죽었습니다.
표준은 OAuth 2.0 authorization code 그랜트입니다. 여기에 PKCE를 붙였습니다.
PKCE를 붙인 근거는 RFC 9700입니다. 2025년 1월에 나온 OAuth 2.0 보안 모범사례(BCP)로, 공개 클라이언트뿐 아니라 모든 클라이언트 타입에 PKCE를 요구합니다. 흔히 "OAuth 2.1이 PKCE를 의무화했다"고 말하지만, OAuth 2.1은 2026년 현재까지 IETF 드래프트입니다. 규범적 근거는 RFC 9700 쪽이 정확합니다.
반대로 만들지 않기로 한 것도 분명히 정했습니다.
| 만들지 않은 것 | 이유 |
|---|---|
OIDC id_token·JWKS·discovery | 소비자가 하나입니다. 키 회전과 discovery 문서 운영은 순수 비용입니다. userinfo 하나로 충분합니다. |
| refresh token | 세션 만료 후 다시 authorize를 타는 것이 곧 재검증입니다. 토큰 수명 관리가 하나 줄어듭니다. |
| back-channel logout 웹훅 | 엔드포인트 등록·서명·재시도·실패 큐가 붙습니다. 짧은 세션으로 상한을 두는 편이 쌉니다. |
| 동의(consent) 화면 | 1st-party 클라이언트입니다. 사용자가 동의할 제3자가 없습니다. |
| 클라이언트 등록 테이블·관리 화면 | 클라이언트가 하나입니다. 환경변수 배열로 두고, 둘째가 생기면 그때 테이블로 승격합니다. |
이 표가 이번 작업에서 가장 많은 시간을 아꼈습니다. "표준이니까 다 만든다"로 갔으면 JWKS 엔드포인트와 키 회전 운영이 따라왔을 겁니다.
로그인 상태에 따라 세 갈래로 갈립니다. 먼저 이미 로그인된 경우입니다.
로그인이 안 된 경우에는 authorize 화면이 기존 서비스의 로그인 화면으로 보냅니다. 이때 진행 중이던 authorize 요청 주소를 통째로 들려 보냅니다. 로그인이 끝나면 그 주소로 되돌아와 위 흐름의 중간부터 이어집니다.
소속이 다른 경우에는 code를 발급하지 않고 error=access_denied로 돌려보냅니다. 사유는 싣지 않습니다. 계정 상태를 외부에서 열거할 수 있게 되기 때문입니다.
여기서 예상 못 한 제약을 만났습니다. 기존 서비스의 세션 쿠키가 API 호스트의 특정 경로에 묶여 있었습니다.
plainSet-Cookie: refresh_token=…; HttpOnly; Path=/auth; SameSite=None
Path=/auth이므로 /authorize로 가는 최상위 이동에는 이 쿠키가 실리지 않습니다. 백엔드에 GET /authorize를 만들어도 세션을 볼 수 없다는 뜻입니다.
그래서 authorize 진입점을 프론트엔드 라우트로 두었습니다. 브라우저가 세션을 복원하고, 그다음 Bearer 토큰으로 백엔드에 code 발급을 요청합니다. 화면은 아무것도 그리지 않고 판정 결과에 따라 이동만 합니다.
typescriptexport async function resolveAuthorizeTarget(input: Input): Promise<string> { const params = parseAuthorizeParams(input.search) // 되돌려 보낼 주소조차 믿을 수 없으면 리다이렉트하지 않는다 if (!params) return '/' const loginTarget = `/login?redirect=${encodeURIComponent(input.returnTo)}` if (!input.isAuthenticated()) return loginTarget try { const { code } = await input.issueCode({ ... }) return buildRedirectUrl(params.redirectUri, { code, state: params.state }) } catch (error) { // 401은 거절이 아니라 세션 없음이다 — 호출한 서비스로 튕기지 않고 로그인으로 보낸다 if (isUnauthorized(error)) return loginTarget return buildRedirectUrl(params.redirectUri, { error: 'access_denied', state: params.state }) } }
판정 전체를 순수 함수 하나로 뽑아 두면 테스트가 쉬워집니다. 컴포넌트는 결과 주소로 이동만 합니다.
authorization code는 60초짜리 일회성 티켓입니다. Redis가 없는 환경이라 Postgres 테이블로 뒀습니다.
일회성 보장이 핵심입니다. 인스턴스가 여러 대인 환경에서 같은 code로 동시에 두 번 교환이 들어와도 한 번만 성공해야 합니다.
sqlDELETE FROM authorization_code WHERE code = $1 AND expires_at > now() RETURNING client_id, redirect_uri, code_challenge, member_id, role, group_code
DELETE ... RETURNING 한 문장이면 승자가 한 명으로 정해집니다. 조회 후 삭제로 나누면 그 사이가 경합 구간이 됩니다. 만료된 행은 발급할 때 함께 지웁니다. 스윕 스케줄러를 따로 두지 않았습니다.
여기부터가 실제로 시간을 쓴 부분입니다.
증상은 이랬습니다. 로그인까지 정상, 돌아오는 콜백에서 오류 화면.
plainGET /api/auth/callback/... 302 CallbackRouteError details: { "message": "client 인증에 실패했습니다.", "statusCode": 401 }
로그도 code 발급까지는 깨끗했습니다. 죽는 자리는 토큰 교환 한 곳이었습니다.
원인은 제가 스펙을 거꾸로 구현한 것이었습니다. 자격증명을 요청 본문에서만 읽었는데, RFC 6749 §2.3.1은 이렇게 규정합니다.
The authorization server MUST support the HTTP Basic authentication scheme for authenticating clients that were issued a client password.
본문 파라미터 지원은 선택입니다. 그리고 표준 클라이언트 라이브러리 대부분이 기본값으로 Basic을 씁니다. 즉 정상 연동일수록 확실하게 실패합니다.
고치면서 두 가지를 더 배웠습니다.
Basic은 base64 앞에 값을 form-urlencode합니다. 시크릿을 openssl rand -base64로 만들면 +, /, =가 섞이는데, 디코드하지 않으면 조용히 틀린 값을 비교합니다. 실패가 "인증 실패"로만 보여서 원인을 찾기 어렵습니다.
가드는 검증 파이프보다 먼저 돕니다. 요청 DTO에서 client_id를 필수로 두면, Basic으로 인증한 정상 요청이 가드를 통과하고도 400으로 떨어집니다. 두 방식을 받으려면 본문 필드를 선택으로 풀어야 합니다.
가장 뼈아픈 부분은 따로 있습니다. 이 실패를 테스트가 못 잡았습니다. 유닛도 e2e도 본문 방식으로만 호출했기 때문입니다. 커버리지 숫자는 높았고, 정작 표준 클라이언트가 쓰는 경로에는 호출이 한 번도 없었습니다.
TIP. OAuth 토큰 엔드포인트를 만들면 Basic으로 한 번은 호출하는 테스트를 넣으세요. 본문 방식만 검증한 테스트는 "표준 클라이언트와 연동된다"를 증명하지 못합니다.
"로그인 화면까지는 가는데 원래 서비스로 안 돌아온다"는 제보였습니다.
로그인 화면에 복귀 주소를 넘기는 설계 자체는 있었습니다. IdentityServer나 WorkOS가 쓰는 returnUrl과 같은 형태입니다. 오픈 리다이렉트 방어도 되어 있었습니다. 앱 내부 상대 경로만 통과시킵니다.
문제는 소비 지점이었습니다.
파라미터를 읽는 곳이 세 군데인데 한 곳만 읽고 있었습니다. 완료 처리 함수는 이미 복귀 옵션을 지원하고 있었고, 넘기는 쪽만 없었습니다.
이런 종류의 누락은 값을 각자 읽을 때 생깁니다. 그래서 복귀 목적지를 읽는 훅을 하나로 만들고 세 경로가 같은 값을 보게 했습니다. 2단계 인증 경로도 같이 고쳤습니다. 중간 단계에서 목적지를 흘리면 결과는 똑같이 "돌아가지 못함"입니다.
TIP. 여러 인증 경로가 있는 로그인 화면이라면, 복귀 목적지는 한 곳에서 읽어 나눠 주세요. 경로마다 각자 읽으면 새 경로가 추가될 때 조용히 빠집니다.
프레임워크 인터셉터가 모든 응답을 이렇게 감싸고 있었습니다.
json{ "success": true, "data": { ... }, "timestamp": "..." }
내부 API에는 일관성이 있어 좋습니다. 그런데 OAuth 토큰 응답은 RFC 6749 §5.1이 모양을 정해 둔 표준 계약입니다.
json{ "access_token": "...", "token_type": "Bearer", "expires_in": 300 }
봉투가 씌워지면 표준 클라이언트는 access_token을 찾지 못합니다. 토큰·userinfo 엔드포인트만 봉투를 벗겼습니다.
여기서 하나 더. 필드 이름을 카멜케이스로 두고 직렬화 데코레이터로 바꾸려 했는데 동작하지 않았습니다. 인터셉터가 이름을 바꾸는 것은 컨트롤러가 클래스 인스턴스를 반환할 때뿐인데, 이 코드베이스는 평범한 객체를 반환하고 있었습니다. 인터페이스로 두고 스네이크케이스 리터럴을 그대로 쓰는 편이 짧고 사고가 없었습니다.
TIP. 표준이 응답 모양을 정한 엔드포인트에는 사내 응답 규약을 씌우지 마세요. 일관성보다 계약이 우선입니다.
시크릿을 읽는 서비스를 이렇게 썼습니다.
typescriptconstructor(config: ConfigService) { const secret = config.get<string>('TOKEN_SECRET')?.trim() if (!secret) throw new Error('TOKEN_SECRET 이 설정되지 않았습니다.') // ❌ ... }
설정 사고를 조용히 넘기지 않겠다는 의도였습니다. 그런데 이 서비스는 애플리케이션 모듈 그래프에 들어 있습니다. 시크릿이 없는 모든 환경에서 앱 전체가 뜨지 않습니다. API 계약 추출 스크립트가 먼저 죽어서 알았습니다.
기능 하나를 위한 설정이 서비스 전면 중단의 트리거가 되는 것은 거래가 맞지 않습니다. fail-fast에서 fail-closed로 바꿨습니다. 앱은 뜨고, 그 기능만 닫히고, 부팅 로그에 경고가 남습니다.
같은 판단을 클라이언트 목록 파싱에도 적용했습니다. JSON이 깨졌을 때 던지면 시크릿을 한 번 잘못 고친 것이 API 전면 중단이 됩니다. 오류를 크게 기록하고 빈 목록으로 떨어집니다. 결과는 "그 기능만 닫힘"입니다.
TIP. 생성자에서 던지기 전에 물어보세요. "이 설정이 없으면 무엇이 죽어야 하나?" 기능 하나면 그 기능만 닫는 게 맞습니다.
스테이징과 운영이 같은 시크릿 저장소를 씁니다. 처음에는 두 환경에 같은 이름의 시크릿을 붙이려 했습니다.
배포 워크플로를 읽다가 기존 규약을 발견했습니다. 운영은 접미사가 붙은 별도 이름을 씁니다. 이유를 따라가 보니 명확했습니다.
토큰 서명 키를 공유하면, 스테이징이 발급한 access token이 운영의 검증을 통과합니다. 운영 userinfo는 운영 원장의 실제 신원을 돌려줍니다. 스테이징에는 실제 계정의 사본이 있으니, 이것은 실 신원 열람 경로가 됩니다.
환경별로 이름을 나누고, 클라이언트 시크릿도 분리했습니다. 확인은 응답 메시지로 합니다.
| 요청 | 스테이징 | 운영 |
|---|---|---|
| 운영 시크릿으로 교환 | client 인증 실패 | code 가 유효하지 않음 |
| 스테이징 시크릿으로 교환 | code 가 유효하지 않음 | client 인증 실패 |
code 가 유효하지 않음은 클라이언트 인증을 통과해야만 나오는 메시지입니다. 두 줄이 대각선으로 갈리면 격리가 살아 있다는 뜻입니다.
발급 판정은 핸들러 하나에 모았습니다. 전부 통과해야 code가 나옵니다.
마지막 단계가 중요합니다. JWT 클레임만 믿지 않습니다. 토큰은 발급 시점의 사진입니다. 그 뒤에 계정이 정지됐을 수도, 소속이 바뀌었을 수도 있습니다. 그래서 발급 직전에 원장에 한 번 더 묻습니다. userinfo에서도 같은 조회를 반복합니다.
이 재조회가 로그아웃 전파 경로이기도 합니다. back-channel logout 웹훅을 만들지 않은 대신, 연동 서비스의 세션 수명을 1시간으로 두었습니다. 만료되면 다음 접근에서 authorize를 다시 타고, 그 시점에 원장이 다시 판정합니다. 상태 변화가 반영되는 상한이 곧 세션 수명입니다.
redirect_uri는 문자열 정확일치로만 대조합니다. 접두어 일치나 와일드카드를 허용하는 순간 오픈 리다이렉트가 되고, 오픈 리다이렉트는 계정 탈취와 같은 말입니다. 이 판정을 접두어 매칭으로 바꾸는 변형을 테스트에 넣어 두면, 나중에 누가 "편의상" 느슨하게 만들 때 빨간불이 켜집니다.
플로우가 다 돌고 나서야 눈에 들어온 것이 있었습니다. 헤더에 담당자님이 하드코딩되어 있었습니다.
신원을 받아 세션에 싣고 있었는데 화면이 쓰지 않았습니다. 누가 로그인했는지 알 수 없고, 로그아웃할 방법도 없었습니다.
표시 규칙을 정할 때 두 가지를 고려했습니다.
식별자를 함께 보여줍니다. 이 서비스의 이용자는 대부분 사번 계정이라 이름만으로는 동명이인을 가릴 수 없습니다.
세션 복원 중에는 아무 이름도 쓰지 않습니다. 자리만 잡아 둡니다. 여기에 임시 문구를 넣으면 잘못된 신원이 잠깐이라도 화면에 걸립니다.
한 가지 부작용은 정직하게 적어 둡니다. 레이아웃이 세션을 읽으면서 목록 화면의 정적 프리렌더가 풀렸습니다. 다만 데이터 캐시는 그대로였습니다. 목록을 받아 오는 요청 자체에 재검증 주기가 걸려 있어 상위 API 호출량은 늘지 않았습니다. 바뀐 것은 HTML 생성 시점뿐입니다.
작업 자체보다 틀린 방식에서 배운 게 많았습니다.
마지막 하나를 덧붙이면, 로그 한 줄이 추측 열 번보다 빠릅니다. Basic 인증 문제는 콜백 로그의 details 필드 하나로 5분 만에 좁혀졌습니다. 그 전까지 세운 가설은 전부 틀렸습니다.