Jiseong's Web Dev blog
SSR에서 자동 로그인이 깨지는 순간 본문
개요, 쉬워 보이지만 너무 어려운 자동로그인
자동 로그인 구현은 단순해 보이지만, Fullstack Framework(Next.js, SvelteKit 등) 환경으로 넘어오면 이야기가 달라진다. 특히 보안을 위해 RTR(Refresh Token Rotation) 방식을 채택했을 때 발생하는 '병렬 요청에 의한 토큰 무효화' 문제는 굉장히 까다롭다. Fullstack Framework는 서버 사이드가 있다. 서버사이드에서는 이를 어떻게 관리할 것인지, 보안적인 관점에서도 살펴볼 것이다.
RTR 방식의 문제점
백엔드는 보안을 위해 RTR(Refresh Token Rotation) 방식을 사용하고있다. 프론트에서 병렬 요청이 발생할 때 문제가 발생한다. 단 시간에 여러 refresh 요청이 들어오면 첫 요청만 성공하게 되고, 이후 요청의 refresh token은 만료되었으므로 실패하게된다.
이를 위해 프론트엔드에서는 병렬 요청에서 refresh 하는 로직을 다듬는다. 첫 번째 토큰 갱신 요청이 나가는 동안 나머지 요청들을 Queue에 담아 일시 정지시킨다. CSR 위주의 앱이라면 문제가 되지 않는다. SSR등 서버사이드 로직이 개입했을 때, 문제가 발생한다.
Server는 Stateless하다.
Avoid shared state on the server
Svelte-kit 문서 중 Avoid shared state on the server의 일부이다.
브라우저는 상태(State)를 가집니다. 사용자가 애플리케이션과 상호작용하는 동안 데이터가 메모리에 저장되기 때문이죠. 반면, 서버는 상태가 없습니다(Stateless). 응답의 내용은 오직 요청의 내용에 의해서만 결정되어야 합니다.
svelte-kit 뿐만 아니라 모든 서버에 대해 해당되는 내용이다. 브라우저는 사용자가 애플리케이션과 상호작용하는 동안 데이터가 메모리에 저장되는 반면 서버는 각각의 request마다 구분된다. 한 번의 request가 끝날 때마다 메모리는 초기화된다고 생각하면 된다.(물론 DB는 아니다)
서버 사이드에서 상태를 관리 했을 때, 보안적인 문제점 예시
'use client'
export const failedRequests = useStore([]);
위 처럼 실패한 요청을 관리하는 Queue를 클라이언트 사이드에 정의한다면, 유저마다(브라우저마다) 자동으로 격리된다. 유저 개개인의 브라우저 메모리 상에 존재하므로 서로 다른 유저가 같은 Queue를 사용하는 경우가 전혀 존재하지 않는다.
아래와 같이 Server Side module level에서 singleton으로 관리하였다면, 보안 취약점으로 이어질 수 있다.
// refresh.ts
export const failedRequests = [];
A, B 유저 동시에 401 응답을 받았을 때 failedRequests는 서버상의 메모리에 딱 하나 존재하므로 [{A's request}, {B's request}] queue를 공유하게된다. A의 자동로그인이 완료되었을때 failedRequests를 retry한다면, A는 B의 정보를 조회할 수있다.
자동로그인 처리
Server는 Stateless 하다는 사실은 곧 isRefreshing, failedRequests 상태 등을 관리할 수 없고, 병렬 요청에대해 자동 로그인 처리를 할 방안이 없다는 것이다.
서버의 위임, 클라이언트의 핸들링
내가 선택한 방식은 아래와 같다.
- middleware 등에서 주기적으로 token을 갱신한다.
- 클라이언트 사이드에서 병렬 요청에 대한 처리는 완벽히 한다.
- 서버 사이드에서 병렬 요청에 대한 처리는 포기한다.
middleware, proxy 등 에서 'accessToken 만료 10분전'등의 조건을 정해두고, 미리 갱신 한다면 401 요청이 발생하기 전에 로그인을 연장 할 수 있다. 페이지 이동 기준으로만 실행해야한다. 한 페이지에서 여러 요청이 발생할 때, 모두 토큰 갱신 로직을 실행하여 병렬 요청이 발생하는 상황을 없애야한다.
export function middleware() {
if (isRouteChanged) {
// token 연장
}
}
클라이언트 사이드에서는 전역 상태 등으로 관리하면 된다. 브라우저 마다 격리가되는 부분이라 크게 신경쓸 부분없다.
'use client'
export const refreshManager = useStore({
isRefreshing: false,
failedRequests: [],
})
마지막 "서버 사이드에서 병렬 요청에 대한 처리는 포기한다."에서 UX가 저하될 수 있다. 아래에서 더 알아보자.
트레이드 오프하자
포기하지 않고 근본적으로 해결하려면 인증만 담당하는 Auth Server를 따로 분리하는 것이라고 생각한다. Redis 등 캐싱 서버를 활용해 병렬 요청이 들어와도 이를 쿠키(token) 값 등으로 구분하여 lock을 걸 수 있다. 하지만 너무 비용적인 문제가 크다. 배보다 배꼽이 커지는 상황 같다. 물론 대규모 서비스라면 도입해야한다고 본다.
현실적으로 트레이드 오프가 필요하다. 문제는 RTR에서부터 발생한다. 병렬 요청이 발생해도 정상적인 로그인이 가능다하면, 프론트쪽에서 머리아프게 고민 안해도 된다. 백엔드에서 짧은 기간 동안 access/refresh token을 유효한 것으로 인정해주는 방식(Grace Period)을 설정한다면, 쉽게 해결할 수 있다. 물론 보안의 허점은 살짝 올라갈 수 있다. 보안, UX, DX, 인프라 비용 등을 모두 따졌을 때, 작은 서비스에서는 Grace Period 방식이 적합할 수 있다.
Grace Period는 1초 ~ 30초 등 유예기간을 설정하는 것이다. 3개의 요청(R1, R2, R3)이 거의 동시에 들어왔을 때, R1이 먼저 토큰을 갱신하더라도 R2와 R3가 들고 있는 '방금 막 만료된 토큰'을 백엔드가 유효한 것으로 처리해 준다.
// 토큰 갱신 요청 처리 (백엔드)
function refreshTokens(refreshToken) {
const tokenData = db.find(refreshToken);
// 1. 이미 사용된 토큰인 경우
if (tokenData.isUsed) {
const isWithinGracePeriod = Date.now() - tokenData.usedAt < 10 * 1000; // 10초 내
if (isWithinGracePeriod) {
// 🟢 병렬 요청으로 판단: 기존에 발급했던 새 토큰을 다시 반환
return tokenData.lastIssuedTokens;
}
// 🚩 유예 기간 만료: 진짜 보안 위협으로 간주하고 세션 파기
throw new SessionRevokedError();
}
// 2. 처음 갱신 시도 시
const newTokens = generateNewTokens();
db.update(refreshToken, {
isUsed: true,
usedAt: Date.now(),
lastIssuedTokens: newTokens // 유예 기간용 백업
});
return newTokens;
}
느낀 점
서버에 대해 기초적인 관점에서 다시 돌아봤다. 서버는 stateless 하다는 것, 서버의 메모리를 공유하면 취약점이 발생할 수 있다는 것. 자동 로그인에서의 에러는 누구의 책임인가에 대해서도 생각해 보는 시간이었다. 이제야 자동로그인에 대해 제대로 이해가 되는 것 같다. 기회가 된다면 auth server를 구축하는 것도 해보고싶다.