세션 보안
BFF 게이트웨이의 Redis 기반 토큰 보호 설계
BFF 게이트웨이에서 토큰을 안전하게 다루려면 Redis 세션 구조, Refresh Token Rotation, JWKS 로컬 검증, 세션 만료 이중 제한을 올바르게 설계해야 한다. 잘못 설계하면 토큰 재사용 공격이나 세션 고정 공격에 노출된다.
개념
BFF 게이트웨이는 Keycloak에서 받은 access_token과 refresh_token을 Redis에 보관하고, 브라우저에는 세션 ID 쿠키(sid)만 발급한다. 브라우저가 토큰을 직접 다루지 않으므로 XSS로 토큰을 탈취할 수 없다. 다만 세션 자체가 공격 대상이 되므로 Redis 저장 구조와 토큰 갱신 흐름을 정확하게 설계해야 한다.
Redis 세션 구조
세션 하나에 대해 Redis에 여러 key가 존재한다.
session:{sid} → HASH
access_token : Keycloak access_token (JWT)
refresh_token_hash : SHA-256(refresh_token) — 해시만 저장
user : JSON {"sub", "email", "roles", "companies", ...}
created_at : unix timestamp (최초 로그인 시각)
last_seen_at : unix timestamp (마지막 요청 시각, 매 요청마다 갱신)
absolute_expires_at : unix timestamp (절대 만료 시각)
exchanged_tokens : JSON {"hr": {"token": "...", "expires_at": ...}, ...}
session:{sid}:rt → STR (refresh_token 평문)
session:{sid}:it → STR (id_token — 로그아웃 시 Keycloak SSO 종료용)
refresh_token을 분리하는 이유
refresh_token 평문을 HASH에 두면 HASH를 읽는 모든 경로에서 노출 가능성이 생긴다. 메인 HASH에는 SHA-256 해시만 두고, 실제 Keycloak 호출 시에만 별도 key(session:{sid}:rt)에서 꺼낸다. 해시는 재사용 감지에만 쓴다.
user_sessions 인덱스
user_sessions:{sub} → SET of sid
한 유저가 여러 기기로 로그인한 경우 sub 기준으로 모든 sid를 추적한다. 강제 로그아웃이나 보안 이벤트 발생 시 해당 유저의 세션 전체를 폐기할 수 있다.
Refresh Token Rotation
access_token이 만료되기 전에 refresh_token으로 새 토큰을 발급받는다. 이때 이전 refresh_token은 즉시 무효화된다(RFC 6749 권장, OAuth Security BCP 강력 권장).
분산 락
여러 요청이 동시에 들어올 때 Keycloak에 refresh 요청이 중복으로 가면 안 된다. Keycloak은 refresh_token을 일회성으로 처리하므로 두 번째 요청은 실패한다.
per-session 락: session:{sid}:refresh_lock
→ 락 획득한 요청이 Keycloak 호출
→ 대기 요청은 락 해제 후 갱신된 세션 사용
Lua 스크립트로 락 소유자 확인 후 삭제해 안전하게 해제한다.
재사용 감지(Refresh Token Reuse Detection)
정상 흐름에서는 rotation 후 이전 refresh_token이 무효화된다. 누군가 이전 refresh_token을 훔쳐서 쓰려 하면 이미 갱신된 새 토큰과 해시가 달라 감지된다.
감지 시 → 해당 유저의 모든 세션 즉시 폐기
보안 이벤트 로그 기록
Grace Window 트레이드오프
네트워크 중복 요청 상황에서 false positive가 생길 수 있다. 30초 내 재사용은 허용하는 grace window를 두기도 한다(Auth0 등 상용 IdP도 사용). 보안과 UX의 트레이드오프다. grace window를 두더라도 같은 창 안에서 두 번 이상은 거부한다.
JWKS 로컬 검증
access_token을 검증할 때마다 Keycloak에 요청하면 Keycloak이 단일 장애점이 된다. JWKS 공개키를 캐시해서 로컬에서 검증한다.
Keycloak: access_token을 자신의 private key로 서명 (RS256)
게이트웨이: Keycloak JWKS 엔드포인트에서 public key 캐시 (1시간 TTL)
→ 캐시된 public key로 서명 검증
→ Keycloak 의존 없이 로컬 처리
RS256: RSA + SHA-256. Keycloak의 private key로 서명하고 public key로 검증한다. public key가 노출돼도 서명 위조는 불가능하다.
키 교체 대응: JWTError 발생 시 캐시를 무효화하고 JWKS를 다시 가져와 한 번 재시도한다. Keycloak 키 교체(rotation)에 무중단으로 대응하기 위한 패턴이다.
검증 항목: 서명, issuer(iss), audience(aud), 만료시각(exp), not-before(nbf).
세션 만료
두 가지 만료 기준을 동시에 적용한다.
Idle Timeout (비활동 만료)
마지막 요청 시각(last_seen_at) 기준으로 N시간 이상 미사용 시 만료. 매 요청마다 last_seen_at과 Redis TTL을 갱신(슬라이딩 윈도우)한다.
Absolute Timeout (절대 만료)
로그인 시각(created_at) 기준으로 최대 N시간. 아무리 활성 사용자여도 이 시간이 지나면 재로그인을 요구한다.
idle만 있으면: 계속 쓰는 사용자는 세션이 무기한 유지
absolute만 있으면: 짧은 사용 후 오래 쉬어도 시간이 지나면 만료 안 됨
둘 다 필요한 이유: 탈취된 세션도 결국 강제 만료
Backchannel Logout
Keycloak이 서버 측에서 직접 게이트웨이 세션을 폐기하는 OIDC 스펙 기능이다. 관리자가 Keycloak에서 유저를 강제 로그아웃시키거나, 다른 클라이언트에서 로그아웃이 발생했을 때 게이트웨이 세션도 함께 폐기된다.
Keycloak → POST /auth/backchannel-logout (logout_token JWT)
게이트웨이: logout_token 서명·claims 검증
→ sub 추출
→ user_sessions:{sub}의 모든 sid 폐기
logout_token에는 nonce 클레임이 없어야 하고, http://schemas.openid.net/event/backchannel-logout 이벤트가 포함돼야 한다(OIDC Back-Channel Logout Spec).
더 보기
- PKCE — 세션 생성 전 단계, 토큰을 안전하게 받아오는 방법
- Token-Exchange — 세션 내 exchanged_tokens 캐시 활용 방식
- BFF — 세션 보안이 필요한 아키텍처 배경
- API-Gateway — 세션을 실제로 운용하는 게이트웨이 구조
- XSS-CSRF — 세션 쿠키를 노리는 공격들