TT Lab
블로그

한국어

시작하기
블로그

블로그

SSO 연동의 두 방식: 앱 안의 에이전트(SDK)와 앱 앞의 게이트웨이

상태: 초안 (2026-10-26), 실측 반영. 실측은 셋입니다. ① TT Lab 클러스터의 Keycloak 에서 누구나 읽을 수 있는 공개 메타데이터(/.well-known/openid-configuration, JWKS)를 읽었습니다. 호스트·렐름 이름과 판 번호는 가렸습니다. ② 노트북에 실습용 Keycloak(dev 모드, 운영과 별개인 실습 환경) 을 새로 띄우고 실습 렐름·클라이언트·사용자를 난수 값으로 만들어 Authorization Code + PKCE 를 파이썬 표준 라이브러리만으로 돌렸습니다. 실제 사용자 계정으로는 로그인하지 않았습니다. ③ 실습용 키로 만든 JWT 를 로컬 게이트웨이(agentgateway)에 보내 판정을 봤습니다. 실습 키의 비공개 부분은 글에 넣지 않았습니다. 명세는 같은 날 원문을 열어 확인했습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 확인하지 못한 것은 「확인 못 함」, 짐작은 「추정」으로 적습니다.

목차

1. 출입증은 한 곳에서, 확인은 어디서?

회사 건물을 떠올려 봅시다. 출입증은 보안실 한 곳에서 발급합니다. 이것이 IdP(Identity Provider, 예: Keycloak)입니다. 출입증을 확인하는 방법은 둘입니다.

두 방식은 같은 규약(OIDC) 을 씁니다. 다른 것은 그 규약을 어느 프로세스가 수행하느냐뿐입니다. 그래서 먼저 규약을 보고(2–4절), 그것을 앱 안에서 할 때(5절)와 앱 앞에서 할 때(6절)를 비교한 다음, 로그아웃(7절)과 함정(8절)으로 갑니다.

2. OAuth 2.0, OIDC, SAML: 무엇이 무엇을 푸나

OAuth 2.0 OpenID Connect (OIDC) SAML 2.0
푸는 문제 인가 위임: 앱이 사용자 대신 API 를 부를 권한 인증: 「이 사람이 누구인가」를 앱에 알림 인증(과 속성 전달), 주로 기업 웹 SSO
결과물 액세스 토큰(형식 자유) ID 토큰(반드시 JWT) + OAuth 의 토큰들 서명된 XML Assertion
위치 기반 OAuth 2.0 위에 얹은 층 별개 규약(2005)
찾기 — /.well-known/openid-configuration(Discovery) 메타데이터 XML
전달 리디렉트 + 뒤채널 /token 같음 HTTP-Redirect(요청)·HTTP-POST(응답) 바인딩

OAuth 2.0 만으로는 「로그인」이 안 된다는 점이 중요합니다. 액세스 토큰은 API 를 위한 것이지 「누가 로그인했는지」를 앱에 증명하는 문서가 아니기 때문입니다. OIDC 가 그 빈칸을 ID 토큰으로 메웁니다. OIDC Core §2 는 ID 토큰에 iss·sub·aud·exp·iat 를 반드시 넣게 합니다.

SAML 의 SP 시작 흐름도 모양은 비슷합니다. SP(앱)가 AuthnRequest 를 압축해 리디렉트로 보내고, IdP 가 로그인시킨 뒤 서명된 Response 를 HTML 폼의 POST 로 앱의 ACS 주소에 넘깁니다(OASIS SAML 2.0 기술 개요 §5.1.2, §4.6). 이 글은 새로 만드는 연동에서 주로 쓰는 OIDC 를 중심으로 봅니다. SAML 의 규범 문서(core·bindings PDF)는 본문을 직접 인용하지 못했고, 개요 문서로 확인했습니다.

3. Authorization Code + PKCE: 토큰이 브라우저를 지나지 않게

Authorization Code + PKCE: 토큰은 브라우저를 지나지 않는다 브라우저 사용자 앱 (SDK) 또는 게이트웨이 IdP Keycloak 등 ① 보호된 페이지 요청 ② verifier 난수 → challenge S256(verifier). state·nonce 는 세션에 ③ 302 → IdP /auth challenge · state · nonce · redirect_uri ④ 로그인 화면 · 비밀번호 · MFA IdP 쿠키 = SSO 세션 ⑤ 302 → redirect_uri?code&state&iss code 는 한 번만, 짧게(초 단위) ⑥ /callback 도착 state 가 저장한 값과 같은가 ⑦ 뒤채널 POST /token code + verifier + 클라이언트 인증 ⑧ ID · 액세스 · 리프레시 토큰 ⑨ 서명·iss·aud·exp·nonce 확인 → 앱 세션 쿠키만 브라우저로 주황 화살표만 토큰이 오간다. 가로챈 code 만으로는 verifier 가 없어 교환이 안 된다. 실측(노트북 실습용 Keycloak): ③–⑥ 117ms, ⑦–⑧ 9ms.
그림: 브라우저, 앱, IdP 사이에서 Authorization Code + PKCE 가 오가는 아홉 단계

흐름의 핵심은 브라우저를 통해서는 짧게 사는 code 만 건너가고, 토큰은 앱과 IdP 가 뒤채널로 직접 주고받는다는 것입니다. 브라우저 주소창, 히스토리, 리퍼러, 확장 프로그램은 모두 새는 곳이기 때문입니다. 예전의 implicit 흐름은 토큰을 주소의 #fragment 로 브라우저에 바로 줬고, OAuth 보안 BCP(RFC 9700, 2025-01) §2.1.2 는 이제 implicit 을 쓰지 말라고 합니다. 같은 문서는 비밀번호를 앱이 받아 IdP 에 넘기는 password grant 도 금지(§2.4)합니다.

세 개의 난수: state, nonce, PKCE

로그인 요청 하나에 난수가 셋 붙습니다. 하는 일이 서로 다릅니다.

난수 어디서 확인하나 막는 것
state 콜백에서 앱이 남이 만든 콜백을 내 브라우저에 밀어 넣는 CSRF
nonce ID 토큰 안에서 앱이 다른 로그인의 ID 토큰을 재생하는 것
PKCE code_verifier /token 에서 IdP 가 가로챈 code 를 다른 사람이 토큰으로 바꾸는 것

PKCE(RFC 7636)의 계산은 한 줄입니다. 앱이 43–128자의 난수 code_verifier 를 만들고(§4.1), 로그인 요청에는 그 해시 code_challenge = BASE64URL(SHA256(code_verifier)) 만 보냅니다(§4.2, S256). code 를 토큰으로 바꿀 때 원래 verifier 를 내야 하므로, code 만 훔친 쪽은 아무것도 못 합니다.

import base64, hashlib, secrets
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
verifier = b64u(secrets.token_bytes(32))                       # 43자
challenge = b64u(hashlib.sha256(verifier.encode()).digest())
print(b64u(hashlib.sha256(b"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk").digest()))

마지막 줄은 RFC 7636 부록 B 의 예제 verifier 입니다. 로컬에서 돌리니 E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM 로, 부록의 기대값과 같았습니다(2026-10-26). RFC 9700 §2.1.1 은 공개 클라이언트(비밀을 못 지키는 SPA·모바일 앱)는 PKCE 를 반드시 쓰라고 하고, 비밀이 있는 서버 앱에도 권합니다.

실측: 로컬 Keycloak 으로 한 바퀴

실습용 Keycloak 에 비밀을 가진(confidential) 클라이언트 하나를 만들고, PKCE 를 S256 으로 강제하고, redirect URI 를 정확히 하나만 등록했습니다. 그다음 브라우저 역할을 파이썬 urllib 로 흉내 내어 위 그림의 ③–⑨ 를 그대로 밟았습니다.

단계 결과(2026-10-26, 노트북, 로컬 IdP)
③–⑥ 로그인 화면 → 비밀번호 제출 → 콜백 117–122ms. 콜백에 code·state·session_state·iss 네 값
code 길이 110자
⑦–⑧ /token 교환 200, 9ms. 받은 것: access_token·id_token·refresh_token·expires_in 등 9개 키
같은 code 를 한 번 더 400 invalid_grant 「Code not valid」
verifier 를 틀리게 400 invalid_grant 「PKCE verification failed: Code mismatch」
로그인 요청에 PKCE 를 빼면 IdP 가 콜백으로 invalid_request 「Missing parameter: code_challenge_method」
redirect_uri 끝에 / 하나 붙이면 400 오류 화면 「Invalid parameter: redirect_uri」, 콜백으로 돌아가지도 않음

콜백에 iss 가 붙어 오는 것은 IdP 가 authorization_response_iss_parameter_supported 를 켠 덕분입니다. 앱이 IdP 를 여럿 쓸 때 「이 code 를 어느 IdP 가 보냈나」를 헷갈리게 만드는 혼동 공격(mix-up)을 막습니다.

한 가지는 예상보다 셌습니다. 같은 code 를 다시 쓰자, 그 code 로 이미 받아 둔 토큰의 세션까지 끊겼습니다. 재사용 시도 직후 그 세션의 액세스 토큰을 introspection 하면 active: false, 리프레시 토큰으로 갱신하면 400 「Session doesn't have required client」였습니다. code 가 두 번 쓰였다는 것은 누군가 code 를 가로챘다는 신호이므로, 이미 나간 토큰도 믿지 않겠다는 동작입니다. RFC 6749 §4.1.2 가 권하는 바로 그 처리입니다. 다만 그 액세스 토큰은 서명 검증만 하는 쪽에서는 여전히 유효했습니다(7절의 문제와 같습니다).

4. 토큰 세 가지와 JWT 서명 검증

토큰 받는 쪽 무엇에 쓰나 실측 수명(실습 렐름 설정) 실측 크기
ID 토큰 앱(클라이언트) 「누가 로그인했나」. 앱 세션을 만들 때 한 번 300초 1,145바이트
액세스 토큰 API(리소스 서버) API 호출의 Authorization: Bearer 300초 1,403바이트
리프레시 토큰 IdP 만 새 액세스 토큰 받기 1,800초(SSO 세션 유휴 한도와 같음) —

액세스·ID 토큰은 쿠키 하나(보통 4KB 한도)의 3분의 1 이상을 차지합니다. 게이트웨이가 토큰을 쿠키에 넣는 방식이면 여러 쿠키로 쪼개거나 서버 쪽 세션 저장소를 써야 하는 이유입니다.

JWT 세 조각과, 받는 쪽이 지켜야 할 검사 순서 헤더 페이로드(클레임) 서명 . . alg · typ · kid iss · sub · aud · exp · iat · nonce RS256: 개인키로 서명 base64url 로 감싼 JSON — 암호화가 아니라 그냥 보인다 1 alg 를 내가 정한 목록으로 고정 none · HS256 바꿔치기 거절 2 kid 로 JWKS 의 공개키 고르기 모르는 kid 면 JWKS 다시 받기(횟수 제한) 3 서명 검증 s^e mod n 이 SHA-256 과 맞나 4 iss 가 글자까지 같은가 끝의 / 하나도 다르면 실패 5 aud 에 내 이름이 있는가 다른 앱에 준 토큰 재사용 막기 6 exp · nbf (시계 오차 허용) 보통 60초 안팎 7 ID 토큰이면 nonce 로그인 요청과 묶였나 1–3 은 「누가 만들었나」, 4–7 은 「나에게, 지금, 이 로그인으로 온 것인가」.
그림: JWT 의 헤더·페이로드·서명 세 조각과, 받는 쪽이 지켜야 할 일곱 가지 검사 순서

JWT 는 세 조각

JWT(RFC 7519)는 JWS(RFC 7515)의 압축 형식으로 BASE64URL(헤더).BASE64URL(페이로드).BASE64URL(서명) 입니다. base64url 은 암호화가 아닙니다. 누구나 디코드해서 읽을 수 있으므로 토큰에 비밀을 넣으면 안 됩니다. 실측한 토큰의 헤더는 {"alg":"RS256","typ":"JWT","kid":"…"} 였고, kid 는 IdP 의 공개키 목록(JWKS, RFC 7517)에서 어느 키로 검증할지 고르는 이름표입니다.

RS256 검증은 결국 거듭제곱 한 번입니다. 서명 s 를 공개키 (n, e) 로 s^e mod n 하면 PKCS#1 v1.5 모양(00 01 FF … FF 00 + DigestInfo + SHA-256(헤더.페이로드))이 나와야 합니다. 이해를 위해 표준 라이브러리로 직접 써 봤습니다(운영 코드에서는 검증된 라이브러리를 쓰십시오).

def rs256_ok(token, jwk):
    h64, p64, s64 = token.split(".")
    n = int.from_bytes(ub64(jwk["n"]), "big"); e = int.from_bytes(ub64(jwk["e"]), "big")
    k = (n.bit_length() + 7) // 8
    em = pow(int.from_bytes(ub64(s64), "big"), e, n).to_bytes(k, "big")
    di = bytes.fromhex("3031300d060960864801650304020105000420") + hashlib.sha256(f"{h64}.{p64}".encode()).digest()
    return em == b"\x00\x01" + b"\xff" * (k - 3 - len(di)) + b"\x00" + di

실측: 실습 IdP 가 준 액세스·ID·로그아웃 토큰 모두 JWKS 의 2048비트 RSA 키로 이 함수를 통과했고, 페이로드 한 비트만 바꾸면 실패했습니다.

서명이 맞아도 끝이 아니다

서명은 「IdP 가 만들었다」만 증명합니다. 「나에게, 지금, 이 로그인으로 온 토큰인가」는 클레임으로 따로 확인해야 합니다. OIDC Core §3.1.3.7 이 ID 토큰 검증을 13단계로 적어 두었고, 핵심은 그림의 4–7번입니다. iss 는 글자까지 같아야 하고, aud 에 내 client_id 가 있어야 하고, exp 가 지나지 않았어야 하고, nonce 가 내가 보낸 값이어야 합니다. JWT 모범 사례(RFC 8725)는 라이브러리가 받아들일 알고리즘 목록을 호출자가 정하게 하라(§3.1), aud 를 반드시 검사하라(§3.9)고 합니다. alg: none 은 「명시적으로 요청하지 않는 한 받지 말라」(SHOULD NOT, §3.2)입니다.

실측한 토큰에서 함정 둘이 보였습니다.

  1. Keycloak 액세스 토큰의 aud 는 기본으로 account 였습니다. 내 API 의 이름이 아닙니다. API 쪽에서 aud 를 제대로 검사하려면 IdP 에 audience 매퍼를 붙여 API 이름이 들어가게 해야 합니다. 귀찮다고 API 에서 aud 검사를 끄면, 같은 IdP 가 다른 앱에 준 토큰으로 내 API 를 부를 수 있게 됩니다.
  2. JWT 액세스 토큰 표준(RFC 9068)은 헤더 typ 을 at+jwt 로 하라고 하는데, 실측 토큰은 JWT 였습니다(클레임 typ 은 Bearer). RFC 9068 §4 대로 typ 을 엄격히 보는 리소스 서버라면 이 토큰을 거절합니다. 어느 쪽을 맞출지 연동 전에 정해야 합니다.

exp 에는 시계 오차 허용(leeway)이 붙습니다. RFC 7519 §4.1.4 는 「보통 몇 분을 넘지 않는 작은 여유」를 허용합니다. 실측 ID 토큰을 만료 1초 뒤의 시계로 검사하면 여유 없이는 exp 실패, 60초 여유로는 통과였습니다. Spring Security 리소스 서버의 기본 여유가 60초(공식 문서)이고, 6절의 게이트웨이도 실측으로 약 60초였습니다.

클러스터 IdP 의 공개 메타데이터

TT Lab 클러스터의 Keycloak 에서 Discovery 문서와 JWKS 를 읽었습니다(2026-10-26). 둘 다 인증 없이 열리는 공개 정보이고, 아래 값은 Keycloak 이 렐름마다 기본으로 내놓는 목록입니다.

항목 값
Discovery 문서 키 수 57
엔드포인트 authorization·token·userinfo·end_session·jwks·introspection·revocation·device_authorization·PAR·CIBA·registration
grant_types_supported 10개: authorization_code·refresh_token·client_credentials·device_code·token-exchange·jwt-bearer·uma-ticket·ciba, 그리고 implicit·password
code_challenge_methods_supported plain, S256
id_token_signing_alg_values_supported 13개(RS·PS·ES·HS 계열과 EdDSA)
로그아웃 backchannel·frontchannel 모두 지원, 세션(sid) 포함
JWKS 키 2개: 서명용 RS256(2048비트), 암호화용 RSA-OAEP

여기서 볼 점은 Discovery 문서는 서버가 「할 수 있는 것」의 목록이지, 클라이언트에 「허용된 것」이 아니라는 것입니다. implicit·password·plain 이 목록에 있어도 클라이언트별로 끌 수 있고, 꺼야 합니다. 클라이언트마다 표준 흐름(code)만 켜고 implicit·직접 접근(password) 흐름은 끄는 것이 기본 자세입니다.

5. 에이전트(SDK) 방식: 앱 안의 라이브러리가 하는 일

같은 OIDC, 다른 자리: 앱 안에서 하나, 앱 앞에서 하나 에이전트(SDK) 방식 브라우저 앱 프로세스 OIDC 라이브러리 리디렉션·콜백·세션·갱신·로그아웃 업무 코드가 사용자·역할을 직접 본다 IdP 뒤채널 /token · 로그아웃 게이트웨이(프록시) 방식 브라우저 게이트웨이 oauth2-proxy: 로그인·쿠키 Envoy·agentgateway: JWT 서명·aud·exp 검사 통과하면 헤더를 붙여 넘김 앱 헤더만 읽음 IdP 뒤채널 SDK 게이트웨이 앱 코드 변경 필요(언어마다) 거의 없음 세분화된 권한 쉽다(코드 안) 헤더·경로 단위 앱이 직접 노출되면 그래도 막힌다 뚫린다(우회 경로 금지) 로그아웃 반영 뒤채널 받으면 즉시 쿠키·토큰 수명까지
그림: 에이전트(SDK) 방식과 게이트웨이 방식의 구성, 그리고 다섯 가지 비교

앱 안의 OIDC 라이브러리는 위 3–4절을 전부 대신합니다. 목록으로 적으면 생각보다 깁니다.

  1. 시작할 때 Discovery 문서를 읽고 JWKS 를 받아 캐시한다. 모르는 kid 가 오면 다시 받는다(횟수 제한을 두고).
  2. 보호된 경로에 세션 없이 오면 state·nonce·verifier 를 만들어 서버 쪽 세션에 저장하고 IdP 로 리디렉트한다.
  3. 콜백에서 state 를 대조하고, code 와 verifier 로 토큰을 받고, ID 토큰을 검증한다.
  4. 앱 세션 쿠키(HttpOnly·Secure·SameSite)를 발급한다. 토큰은 서버에 둔다.
  5. 액세스 토큰이 만료되기 전에 리프레시 토큰으로 갱신한다. 실패하면 다시 로그인시킨다.
  6. 로그아웃: 앱 세션을 지우고, IdP 의 end_session_endpoint 로 보낸다(RP-Initiated Logout). IdP 가 보내는 백채널 로그아웃 요청을 받을 엔드포인트도 연다(7절).
  7. API 를 제공하는 쪽(리소스 서버)이면 들어오는 Bearer 토큰의 서명·iss·aud·exp 를 검사한다.

로컬 실측에서 파이썬으로 이 과정을 흉내 낸 코드가 약 150줄이었고, 그마저 세션 저장, 키 회전, 동시성은 빠져 있습니다. 직접 짜지 말고 검증된 라이브러리를 쓰는 이유입니다.

어떤 라이브러리가 있나, 그리고 Keycloak 어댑터의 퇴장

예전에는 Keycloak 이 언어별 어댑터를 직접 냈습니다. 지금은 대부분 사라졌습니다. 공식 블로그를 따라가면 순서는 이렇습니다.

날짜 일
2022-02 OIDC 자바 어댑터(WildFly·Spring Boot 등)와 Node.js 어댑터 폐기 예고. 대안으로 각 플랫폼의 표준 OIDC 지원(WildFly 기본 OIDC, Spring Security, Quarkus OIDC) 권장
2023-03 지원 기간 연장 공지. 대안 예시로 Spring Security, Elytron OIDC, Quarkus OIDC
2023-11, 23.0.0 WildFly·EAP 용 OIDC 어댑터 제거
2024-06, 25.0.0 Tomcat·서블릿 필터·Spring·Spring Boot 어댑터 등 제거
2025-02 브라우저용 keycloak-js 는 유지되며 별도 저장소·별도 출시 주기로 분리

이 흐름의 의미는 분명합니다. IdP 회사가 언어마다 SDK 를 붙들고 있을 필요가 없을 만큼 OIDC 가 표준화됐다는 것입니다. 자바라면 Spring Security 가 대표입니다.

# 로그인(oauth2Login) — 앱이 OIDC 클라이언트가 된다
spring.security.oauth2.client.provider.idp.issuer-uri: https://<IdP>/realms/<렐름>
spring.security.oauth2.client.registration.idp: { client-id: <앱>, client-secret: <비밀>, scope: [openid, profile] }
# 토큰 검사(oauth2ResourceServer) — API 가 Bearer 를 검증한다
spring.security.oauth2.resourceserver.jwt.issuer-uri: https://<IdP>/realms/<렐름>

issuer-uri 하나로 Discovery 문서에서 JWKS 주소를 찾아오고(첫 요청 때), 시계 여유는 60초가 기본입니다(Spring Security 문서). Spring Boot 4.x 에서는 스타터 이름이 spring-boot-starter-security-oauth2-client·…-resource-server 로 바뀌고 옛 이름은 폐기 예정입니다. 로그인한 사용자의 인가된 클라이언트(토큰)는 기본으로 메모리의 OAuth2AuthorizedClientService 에 둡니다. 세션에 두는 것은 익명 요청뿐입니다. 앱을 여러 대로 늘리면 이 저장소를 공유 저장소로 바꿔야 합니다.

Argo CD, Grafana, Harbor, Gitea 같은 제품은 안에 OIDC 클라이언트가 들어 있어서 설정에 IdP 주소와 클라이언트 비밀만 넣으면 됩니다. 이것도 에이전트 방식입니다. 라이브러리를 우리가 넣었느냐, 제품이 넣어 왔느냐의 차이일 뿐입니다.

6. 게이트웨이 방식: 앱 앞에서 대신 확인한다

앱을 고칠 수 없거나(남이 만든 대시보드), 언어가 제각각인 서비스가 여럿이면 입구에서 한 번에 처리하는 편이 낫습니다. 게이트웨이가 하는 일은 두 갈래입니다.

두 갈래를 겹쳐 쓰는 구성도 흔합니다. 로그인 프록시가 PKCE 로 로그인시키고 IdP 의 그룹 클레임으로 들어올 사람을 거른 뒤 토큰을 Authorization 헤더로 붙이면, 그 뒤의 서비스 메시가 같은 JWT 를 다시 검사하는 식입니다. 이때 원칙은 하나입니다. 프록시의 쿠키 갱신 주기는 IdP 의 액세스 토큰 수명보다 짧게, 쿠키 만료는 IdP 의 SSO 세션 최대 길이를 넘지 않게 둡니다. 그래야 뒤에서 JWT 를 검사하는 쪽이 만료된 토큰을 보기 전에 프록시가 새 토큰을 받아 두고, IdP 에서 세션이 끝난 사람이 쿠키만으로 계속 들어오지 못합니다. oauth2-proxy 의 공식 설정 표에서 쿠키 갱신(cookie_refresh)은 기본값이 비어 있고 0 이면 꺼지며, 쿠키 만료(cookie_expire)의 기본값은 168시간(7일)입니다. 토큰을 뒤로 넘기는 구성이라면 둘 다 IdP 설정에 맞춰 직접 정해야 합니다.

실측: 게이트웨이는 어떤 토큰을 거르나

실습용 RSA 키로 JWT 를 만들어, jwtAuth(strict, iss·aud 지정, JWKS 파일) 를 켠 agentgateway 를 로컬에 띄우고 요청을 한 건씩 보냈습니다(2026-10-26).

보낸 토큰 결과 게이트웨이가 남긴 이유
정상 200 —
토큰 없음 401 no bearer token found
aud 가 다른 앱 401 InvalidAudience
iss 가 다름 401 InvalidIssuer
페이로드 한 글자 바꿈 401 InvalidSignature
alg: none, 서명 없음 401 헤더 해석 실패(알 수 없는 alg)
alg: HS256, 공개키 PEM 을 HMAC 비밀로(알고리즘 혼동) 401 InvalidAlgorithm
모르는 kid 401 unknown key
nbf 가 2분 뒤 401 ImmatureSignature
만료 10초 지남 200 —

마지막 줄이 흥미롭습니다. 만료를 10·30·55·58·62·65·90·120초 전으로 바꿔 보니 58초까지는 통과, 62초부터 거절이었습니다. nbf 도 55초 뒤까지는 통과, 65초 뒤부터 거절이었습니다. 앞뒤 약 60초의 시계 여유가 기본으로 들어 있다는 뜻입니다(문서에서는 확인 못 함, 실측). 만료를 「정확히 그 초」로 믿고 설계하면 안 되는 이유입니다.

비용도 쟀습니다. 같은 백엔드를 직접 부를 때, 인증 없는 게이트웨이를 거칠 때, JWT 검사 게이트웨이를 거칠 때를 400번씩 번갈아 불렀습니다(예열 50번 뒤).

경로 p50 p90 p99
백엔드 직접 0.60ms 0.73ms 1.19ms
게이트웨이(인증 없음) 2.51ms 3.05ms 4.82ms
게이트웨이(JWT 검사) 2.59ms 3.17ms 5.21ms

게이트웨이 한 번을 더 거치는 값(약 1.9ms, 노트북의 컨테이너 망 왕복 포함)에 비해 RS256 검증 자체는 약 0.08ms 였습니다. 검증 비용 때문에 게이트웨이를 망설일 일은 없습니다.

두 방식의 장단점

에이전트(SDK) 게이트웨이
앱 코드 바꿔야 한다(언어·프레임워크마다) 거의 그대로
권한 판단 업무 로직 안에서 세밀하게(이 글의 소유자만 등) 경로·메서드·헤더 단위까지
앱이 다른 길로 노출되면 그래도 막힌다 그대로 뚫린다. 앱으로 가는 다른 길(서비스 직접 노출, 포트포워드)을 네트워크 정책으로 막아야 한다
헤더 신뢰 해당 없음 앱은 게이트웨이가 붙인 헤더만 믿어야 하고, 바깥에서 같은 이름의 헤더가 들어오면 게이트웨이가 지워야 한다
로그아웃 반영 백채널을 받으면 즉시 쿠키·토큰 수명까지 남을 수 있다
운영 앱마다 라이브러리 판 관리 한 곳에서 판·설정 관리, 대신 그곳이 단일 장애점

현실에서는 섞어 씁니다. 브라우저 로그인은 게이트웨이(또는 BFF)가, API 의 세밀한 권한은 앱 안의 리소스 서버 라이브러리가 맡는 식입니다.

7. 로그아웃과 세션 동기화

SSO 의 로그아웃이 어려운 이유는 세션이 세 겹이기 때문입니다. IdP 의 SSO 세션(IdP 쿠키), 앱의 세션(앱 쿠키), 그리고 이미 발급되어 각자 들고 있는 토큰입니다. 하나만 지우면 나머지가 남습니다.

방식 누가 누구에게 특징
RP-Initiated Logout 앱 → IdP 의 end_session_endpoint id_token_hint 권장, post_logout_redirect_uri 는 등록된 값과 정확히 같아야 함
Front-Channel Logout IdP 가 로그아웃 화면에 각 앱 주소를 iframe 으로 브라우저가 서드파티 쿠키를 막으면 실패하기 쉽다
Back-Channel Logout IdP → 앱 서버로 직접 POST 브라우저와 무관. logout_token(JWT)에 events 와 sub 또는 sid, nonce 는 없어야 함

실측: 로그아웃했는데 액세스 토큰은 5분 더 산다

실습 IdP 의 클라이언트에 백채널 로그아웃 주소를 등록하고(노트북에서 받는 작은 HTTP 서버), 새로 로그인한 뒤 리프레시 토큰으로 로그아웃을 요청했습니다.

관찰 값
로그아웃 요청 204
백채널 통지 도착 66ms 뒤, application/x-www-form-urlencoded 의 logout_token
logout_token 헤더 alg: RS256, typ: logout+jwt(명세 권장값)
logout_token 클레임 iss·aud·iat·exp·jti·events·sid·sub·typ. nonce 없음. 수명 120초
events {"http://schemas.openid.net/event/backchannel-logout": {}}
sid 로그인 때 받은 ID 토큰의 sid 와 같음 → 앱은 어느 세션을 지울지 안다
로그아웃 뒤 리프레시 400 「Session not active」
로그아웃 뒤 introspection(RFC 7662) active: false
로그아웃 뒤 같은 액세스 토큰의 서명·만료 검사 통과, 남은 수명 299초

마지막 줄이 핵심입니다. JWT 액세스 토큰은 스스로 증명하는 문서라서, 서명만 보는 API 와 게이트웨이는 IdP 가 세션을 지운 것을 모릅니다. 로그아웃 뒤에도 토큰 수명만큼(여기서는 5분) API 를 부를 수 있습니다. 대응은 셋 중 하나입니다. 액세스 토큰 수명을 짧게 하거나, 중요한 API 는 introspection 으로 IdP 에 물어보거나(매 요청 왕복 비용), 백채널 통지를 받아 그 sid 를 차단 목록에 올립니다.

리프레시 토큰도 비슷한 고민이 있습니다. 실습 렐름의 기본값(회전 꺼짐)에서는 이미 한 번 쓴 리프레시 토큰을 다시 써도 200 이었습니다. 렐름 설정 revokeRefreshToken 을 켜자(회전), 옛 리프레시 토큰은 400 「Maximum allowed refresh token reuse exceeded」로 거절됐고, 그 직후 새 리프레시 토큰까지 「Session doesn't have required client」로 막혔습니다. 재사용을 도난의 신호로 보고 그 세션 전체를 끊는 동작입니다. RFC 9700 §2.2.2 는 공개 클라이언트의 리프레시 토큰은 회전하거나 송신자에 묶으라고 합니다.

8. 흔한 함정

① redirect_uri 불일치. IdP 는 등록된 주소와 문자열로 정확히 비교합니다(RFC 9700 §2.1, 네이티브 앱의 localhost 포트만 예외). 실측에서 끝의 / 하나로 400 화면이 떴습니다. 프록시 뒤에서 앱이 자기 주소를 http:// 로 알고 있거나(TLS 를 앞에서 끝낸 경우), 포트·경로 접두사가 다르면 같은 오류가 납니다. 와일드카드 redirect URI 는 공격자가 code 를 자기 쪽 경로로 받게 만드는 길이 되므로 쓰지 않습니다.

② iss 글자 불일치. 토큰의 iss 와 앱 설정의 issuer 가 http/https, 호스트 이름, 끝의 / 까지 같아야 합니다. IdP 를 내부 주소로 부르고 브라우저는 공개 주소로 가는 구성에서 특히 자주 깨집니다. Keycloak 은 요청받은 호스트 이름으로 iss 를 만들 수 있으므로, 고정된 공개 호스트 이름을 설정해 두는 편이 안전합니다.

③ 시계 어긋남. IdP·게이트웨이·앱 서버의 시계가 다르면 막 받은 토큰이 「아직 유효하지 않음」(nbf·iat)이나 「만료됨」이 됩니다. 실측한 기본 여유는 게이트웨이·Spring 모두 약 60초였습니다. 여유를 늘리기 전에 NTP 부터 맞춥니다.

④ 토큰을 브라우저 저장소에 둔다. SPA 가 액세스·리프레시 토큰을 localStorage 에 두면 XSS 한 번에 통째로 나갑니다. 2026-08 에 RFC 10017(BCP 212)이 된 「브라우저 기반 앱을 위한 OAuth 2.0」은 패턴을 보안 순서로 BFF(Backend for Frontend) → 토큰 중개 백엔드 → 브라우저 단독으로 늘어놓고, 브라우저 단독은 앞서 논한 모든 공격에 취약하다고 적습니다. BFF 는 비밀을 가진 서버 쪽 클라이언트로서 토큰을 서버에 두고 브라우저에는 HttpOnly 세션 쿠키만 줍니다. 즉 5절의 SDK 방식을 서버에서 하는 것입니다.

⑤ aud 를 안 본다. 4절의 Keycloak 기본 aud: account 처럼 토큰의 대상이 내 API 가 아닌데도 받아 주면, 같은 IdP 의 다른 앱 토큰으로 내 API 를 부를 수 있습니다.

⑥ Secure 쿠키와 평문 http. IdP 의 로그인 쿠키에는 Secure 가 붙어 있습니다(실측). 브라우저는 localhost·127.0.0.1 은 안전한 출처로 보고 보내 주지만, LAN 의 평문 http 주소에서는 보내지 않아 「로그인했는데 다시 로그인 화면」이 반복됩니다. 실측 중 파이썬 쿠키 저장소가 같은 이유로 쿠키를 안 보내 400 을 받았습니다.

⑦ JWKS 키 회전. IdP 가 키를 바꾸면 새 kid 의 토큰이 옵니다. 캐시를 오래 들고 있는 검증기(Envoy 기본 10분)는 그동안 거절할 수 있으므로, IdP 는 새 키를 먼저 공개하고 한참 뒤에 서명에 쓰고, 검증기는 모르는 kid 에서 JWKS 를 다시 받게 합니다.

⑧ 게이트웨이 우회와 헤더 위조. 게이트웨이 방식에서 앱이 X-Auth-Request-User 를 믿는다면, 그 헤더를 바깥에서 넣어 앱을 직접 부르는 길이 없어야 합니다.

9. 어느 쪽을 고를까

상황 권하는 쪽
남이 만든 웹 UI(대시보드·노트북) 앞에 로그인을 세워야 한다 게이트웨이(oauth2-proxy)
제품에 OIDC 가 이미 들어 있다(Argo CD·Grafana 등) 제품 내장(=SDK 방식)
우리가 만든 웹앱, 세밀한 권한 서버 쪽 SDK(BFF). Spring Security, 각 언어의 인증 라이브러리
서비스끼리·CLI·에이전트가 Bearer 토큰으로 부르는 API 게이트웨이의 JWT 검사 + 앱 안의 리소스 서버 검사(두 겹)
순수 SPA, 서버 없음 가능하면 BFF 를 하나 둔다. 어렵다면 PKCE + 짧은 토큰 + 회전

10. 정리

티티 한마디. 출입증(토큰)에 도장(서명)이 찍혀 있어도, 「우리 탁구장 거」(aud)인지, 「오늘 날짜」(exp)인지는 따로 봐야 해요. 그리고 퇴장시켜도 손에 든 출입증은 유효기간까지 살아 있으니, 짧게 끊어 주는 게 좋아요.

참고

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다