OAuth 로그인은 왜 state가 필요할까?
들어가며
OAuth 로그인은 하나의 요청으로 끝나지 않는다. 사용자는 서비스에서 로그인을 시작하고, 인증 제공자의 화면으로 이동해 인증한 뒤, 다시 서비스의 콜백 주소로 돌아온다. 서비스는 이때 전달받은 인가 코드(authorization code)를 토큰으로 교환한다.
기존 구현은 콜백에서 인가 코드만 받으면 바로 토큰 교환을 시도했다. 코드는 인증 제공자가 발급했는지 확인할 수 있어도, 그 코드를 가져온 콜백이 현재 브라우저가 시작한 로그인 요청의 결과인지는 확인하지 못했다. 이 빈틈은 공격자가 준비한 콜백을 다른 사용자의 브라우저에서 실행시키는 로그인 CSRF로 이어질 수 있다.
OAuth 로그인이 왕복 요청인 이유
OAuth에서 로그인하려는 서비스를 클라이언트, 실제 인증을 수행하는 시스템을 인증 제공자라고 부른다. 클라이언트는 브라우저를 인증 제공자에게 보낼 때 보통 다음 정보를 함께 전달한다.
client_id: 어떤 애플리케이션이 인증을 요청하는지 나타내는 공개 식별자redirect_uri: 인증을 마친 브라우저가 돌아올 주소state: 로그인 요청과 나중의 콜백을 연결하는 일회용 값
client_id와 redirect_uri는 비밀값이 아니다. 브라우저를 통과하므로 사용자에게 노출될 수 있다. 인증 제공자는 두 값을 이용해 등록된 클라이언트와 허용된 복귀 주소인지 확인한다. 반면 client_secret은 클라이언트를 인증하기 위한 비밀값이므로 브라우저에 전달해서는 안 된다.
인가 코드 방식의 흐름을 단순화하면 다음과 같다.
1
2
3
4
5
1. 사용자가 서비스에서 로그인을 시작한다.
2. 서비스가 인증 제공자 URL을 만들고 브라우저를 이동시킨다.
3. 인증 제공자가 사용자를 인증한다.
4. 브라우저가 code를 가지고 서비스의 redirect_uri로 돌아온다.
5. 서비스가 code를 토큰으로 교환한다.
문제는 1번과 4번이 서로 다른 HTTP 요청이라는 데 있다. 콜백을 받는 시점에 서버는 단순히 code가 있다는 사실만으로 “이 브라우저가 조금 전에 시작한 로그인”이라고 결론 내릴 수 없다.
로그인 CSRF는 무엇을 바꾸는가
일반적인 CSRF(Cross-Site Request Forgery)는 로그인된 브라우저가 사용자의 의도와 다른 요청을 보내게 만드는 공격이다. 브라우저는 대상 사이트의 쿠키를 자동으로 첨부할 수 있기 때문에, 서버가 요청의 출처를 구분하지 않으면 사용자가 직접 보낸 요청처럼 처리할 수 있다.
로그인 CSRF에서는 공격자가 자신의 계정으로 인증을 진행해 얻은 콜백을 피해자의 브라우저에서 실행하도록 유도한다.
1
2
3
4
5
6
7
공격자가 자신의 계정으로 로그인해 code를 준비한다.
↓
피해자의 브라우저가 공격자가 만든 callback?code=... 를 연다.
↓
서비스가 요청의 시작점을 확인하지 않고 code를 교환한다.
↓
피해자의 브라우저가 공격자의 계정으로 로그인된다.
피해자는 자신의 계정으로 로그인했다고 생각하고 서비스에 개인정보나 신청 정보를 입력할 수 있다. 하지만 실제 데이터는 공격자의 계정에 연결될 수 있다. 따라서 인가 코드가 유효한지와 별개로, 콜백이 현재 브라우저에서 시작한 로그인 요청과 연결되는지도 확인해야 한다.
state만 비교하면 충분하지 않은 이유
state는 클라이언트가 로그인 시작 시 생성하고 인증 요청에 포함하는 예측하기 어려운 일회용 값이다. 인증 제공자는 값을 변경하지 않고 콜백에 다시 실어 보낸다. 클라이언트는 처음 만든 값과 돌아온 값이 같은지 검증한다.
1
2
인증 요청: state=random-value
인증 응답: code=authorization-code&state=random-value
여기서 중요한 질문이 남는다.
로그인 전에는 사용자가 누군지 모르는데, 서버는 어떤 브라우저에 어떤 state를 발급했는지 어떻게 기억할까?
state 값만 콜백에 담겨 돌아오는 것은 충분하지 않다. 공격자가 다른 로그인 과정에서 얻은 state와 code를 함께 전달할 수도 있기 때문이다. 서버가 state를 로그인을 시작한 브라우저의 세션에 결합해야 비로소 요청과 응답을 연결할 수 있다.
브라우저에서만 값을 비교하는 방법도 생각할 수 있지만, 토큰을 교환하는 주체는 서버다. 프론트엔드 검증은 우회 가능한 입력 검증일 뿐 서버의 신뢰 경계가 될 수 없다. 최종 비교는 서버에서 수행해야 한다.
로그인 전 브라우저를 구분하는 쿠키
쿠키는 서버가 브라우저에 저장하도록 응답으로 내려주는 작은 값이다. 브라우저는 이후 같은 사이트에 요청할 때 조건에 맞는 쿠키를 자동으로 보낸다.
로그인 전이므로 회원 번호를 저장할 수는 없다. 대신 서버는 아무 개인정보도 뜻하지 않는 임의의 세션 식별자를 발급해 “로그인을 시작한 동일한 브라우저”를 구분할 수 있다.
1
Set-Cookie: sessionId=random-session; HttpOnly; Secure; SameSite=Lax; Max-Age=600
각 속성에는 다음 의도가 있다.
HttpOnly: 브라우저 JavaScript가 쿠키를 읽지 못하게 해 스크립트 노출 범위를 줄인다.Secure: HTTPS 연결에서만 쿠키를 전송한다.SameSite=Lax: 일반적인 외부 사이트의 교차 출처 요청에는 쿠키 전송을 제한하면서, 사용자가 링크를 따라 돌아오는 최상위 탐색에는 사용할 수 있게 한다.Max-Age=600: 로그인 시도와 쿠키의 수명을 10분으로 제한한다.
이 쿠키는 인증된 사용자 세션이 아니다. 서버가 로그인 전후의 두 요청을 같은 브라우저 흐름으로 연결하기 위한 익명 상관관계 식별자다.
서버 세션에 state를 묶다
이번 구현에서는 세션 식별자와 state를 각각 무작위 UUID로 만들고, 다음 관계를 Redis에 10분 동안 저장했다.
1
sessionId -> state
로그인을 시작할 때의 처리는 다음과 같다.
1
2
3
4
1. 서버가 sessionId와 state를 생성한다.
2. Redis에 sessionId -> state를 TTL 10분으로 저장한다.
3. 브라우저에는 sessionId를 HttpOnly 쿠키로 내려준다.
4. 인증 제공자 URL에는 state를 포함한다.
콜백 이후 토큰을 교환할 때는 다음 순서로 검증한다.
1
2
3
4
5
1. 브라우저가 자동으로 보낸 sessionId 쿠키를 읽는다.
2. 콜백에서 받은 state를 요청 본문으로 받는다.
3. Redis에서 sessionId에 연결된 state를 조회한다.
4. 저장된 값과 돌아온 값이 같을 때만 code 교환을 허용한다.
5. 검증한 세션을 삭제해 같은 state의 재사용을 막는다.
값이 없거나 다르면 토큰 교환을 진행하지 않고 요청을 거부한다. 여러 서버 인스턴스가 요청을 처리할 수 있으므로 상태는 특정 프로세스의 메모리 대신 공용 Redis에 저장했다.
브라우저가 보내던 client_id도 토큰 교환 입력에서 제거했다. client_id 자체는 공개값이지만, 서버가 이미 자신의 설정으로 알고 있는 값을 브라우저 입력에 의존할 이유는 없다. 인증 제공자 URL을 만들 때와 코드를 교환할 때 모두 서버 설정을 사용하도록 책임을 모았다.
인증 제공자도 같은 state를 돌려줘야 한다
클라이언트만 변경해서는 흐름이 완성되지 않는다. 인증 제공자는 요청으로 받은 state를 인증 과정 동안 보존하고, 콜백에 같은 값으로 포함해야 한다.
1
2
요청: /authorize?...&state=random-value
응답: /callback?code=...&state=random-value
인증 제공자가 state를 새로 생성하거나 생략하면 클라이언트는 자신이 시작한 요청과 콜백을 연결할 수 없다. 이는 제공자가 값을 신뢰하거나 해석한다는 의미가 아니라, 클라이언트가 검증할 수 있도록 그대로 전달한다는 의미다.
state로 해결되는 것과 남는 것
현재 구현은 검증 직후 세션을 삭제한다. 재사용 공격을 막는 장점이 있지만, 이후 토큰 교환이 일시적으로 실패하면 동일한 콜백을 다시 시도할 수 없다는 트레이드오프가 있다. 또한 Secure 쿠키는 HTTPS에서만 전송되므로 로컬 HTTP 환경은 별도의 설정이 필요하다.
state는 로그인 CSRF를 방어하기 위한 요청-응답 결합 장치이지 OAuth의 모든 위협을 해결하지는 않는다. 인가 코드 탈취와 주입에 대한 더 강한 보호를 위해서는 PKCE도 함께 검토해야 한다. OAuth 2.0 보안 권고인 RFC 9700은 PKCE를 모든 종류의 OAuth 클라이언트에 권고하며, PKCE 지원을 확실히 사용할 수 없다면 브라우저 세션에 안전하게 결합된 일회용 state 또는 OpenID Connect의 nonce로 CSRF를 방어하도록 설명한다.
마무리
이번 문제의 핵심은 인가 코드의 유효성과 로그인 흐름의 정당성이 서로 다른 질문이라는 점이었다.
1
2
이 code는 유효한가?
이 code는 지금 이 브라우저가 시작한 요청의 결과인가?
인증 제공자는 첫 번째 질문에 답하고, 클라이언트는 state와 브라우저 세션을 이용해 두 번째 질문에 답해야 한다. 로그인 전 익명 세션을 쿠키로 식별하고, 서버가 보관한 일회용 state와 콜백 값을 비교한 뒤에만 코드를 교환함으로써 공격자가 만든 로그인 흐름을 피해자의 브라우저에 주입하지 못하도록 검증 경계를 세웠다.