Auth Security

3줄 요약

  • 프론트 인증은 토큰을 어디에 저장할지 하나로 끝나지 않고 XSS, CSRF, Cookie, refresh, logout, multi-tab 흐름을 함께 설계하는 문제다.
  • localStorage 토큰은 쉬운 선택이지만 XSS에 취약하고, Cookie 기반 인증은 CSRF와 SameSite/CORS를 함께 이해해야 안전하다.
  • 백엔드와 401/403, refresh 실패, logout 무효화, Cookie 속성을 정확히 맞추면 프론트 구현이 단순해진다.

핵심 정리

  • access token은 API 접근 권한이고 refresh token은 재발급 권한이다.
  • HttpOnly Cookie는 JavaScript 접근을 막아 토큰 탈취 위험을 줄인다.
  • XSS는 사용자의 브라우저에서 공격자 스크립트가 실행되는 문제다.
  • CSRF는 사용자가 의도하지 않은 인증된 요청이 전송되는 문제다.
  • protected route는 UI 접근 제어일 뿐이고, 백엔드 권한 검사를 대체하지 않는다.

백엔드 개발자가 헷갈리는 지점

  • 프론트에서 route를 막으면 API도 보호된다고 생각하기 쉽다.
    • 판단 기준은 백엔드가 항상 권한을 최종 검증해야 한다는 점이다.
    • protected route는 UX와 navigation 제어다.
  • Cookie를 쓰면 모든 보안 문제가 해결된다고 보기 쉽다.
    • 판단 기준은 XSS와 CSRF의 위협 모델이 다르다는 점이다.
    • SameSite, CSRF token, Origin 검증이 필요할 수 있다.
  • refresh 로직을 컴포넌트마다 넣기 쉽다.
    • 판단 기준은 인증 client 계층에서 한 번만 처리하는 것이다.

개인 프로젝트 기준

확인 질문

  • protected route가 백엔드 권한 검사를 대체할 수 있는가?
    • 아니다. protected route는 사용자 경험이고, API 권한 검사는 서버가 최종 수행해야 한다.
  • HttpOnly Cookie의 보안상 의미는 무엇인가?
    • JavaScript가 Cookie 값을 읽지 못하게 해서 XSS로 인한 토큰 탈취 위험을 줄인다.
  • 401과 403은 어떻게 구분하는 것이 좋은가?
    • 401은 인증 필요 또는 만료, 403은 인증은 되었지만 권한 부족으로 구분한다.

01. 인증 토큰 쿠키 보안 상세

이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • localStorage 토큰 저장은 왜 기본 선택지로 위험한가?
  • Cookie 기반 인증에서 SameSite, Secure, HttpOnly는 어떤 의미인가?
  • 401 refresh와 logout 흐름은 프론트와 백엔드가 어떻게 맞춰야 하는가?

개요

인증은 프론트와 백엔드가 가장 자주 부딪히는 영역이다. 백엔드는 토큰 발급과 검증을 만들고, 프론트는 로그인 상태를 유지하고, 만료를 처리하고, 사용자를 적절한 화면으로 보내야 한다. 여기서 저장소 선택, Cookie 속성, CORS, CSRF, 401/403 정책이 모두 연결된다.

원리

localStorage는 JavaScript에서 쉽게 읽고 쓸 수 있다. 이 편의성은 XSS 상황에서 약점이 된다. 공격자 스크립트가 실행되면 token을 읽어 외부로 보낼 수 있다. HttpOnly Cookie는 JavaScript에서 읽을 수 없지만 요청에는 자동 첨부될 수 있어 CSRF 방어를 함께 고려해야 한다. 즉 저장소 선택은 XSS와 CSRF 중 하나만 고르는 문제가 아니라 위협 모델을 나누어 방어하는 문제다.

refresh token은 access token보다 오래 살고 더 큰 권한을 가진다. 따라서 저장소와 rotation, logout 무효화, 재사용 탐지 정책을 access token보다 엄격하게 봐야 한다.

코드로 보는 핵심 예시

단일 refresh promise로 중복 refresh를 막고, refresh endpoint 자체는 자동 재발급 대상에서 제외한다.

let refreshPromise: Promise<void> | null = null;
 
async function refreshSession() {
  const response = await fetch("/api/auth/refresh", {
    method: "POST",
    credentials: "include",
  });
 
  if (!response.ok) {
    throw new Error("SESSION_EXPIRED");
  }
}
 
export async function authFetch(
  input: RequestInfo,
  init?: RequestInit & { skipAuthRefresh?: boolean },
) {
  const { skipAuthRefresh = false, ...requestInit } = init ?? {};
 
  const response = await fetch(input, {
    ...requestInit,
    credentials: "include",
  });
 
  if (response.status !== 401 || skipAuthRefresh) {
    return response;
  }
 
  refreshPromise ??= refreshSession().finally(() => {
    refreshPromise = null;
  });
 
  await refreshPromise;
 
  return fetch(input, {
    ...requestInit,
    credentials: "include",
  });
}

protected route는 UX 제어이고 API 보안은 서버가 맡는다.

function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const meQuery = useQuery({ queryKey: ["me"], queryFn: getCurrentUser });
 
  if (meQuery.isLoading) return <PageSpinner />;
  if (!meQuery.data) return <Navigate to="/login" replace />;
 
  return children;
}

백엔드와 맞춰야 할 지점

  • refresh Cookie의 HttpOnly, Secure, SameSite, Domain, Path, 만료 시간을 정한다.
  • access token 만료와 refresh 실패를 구분한다.
  • logout 시 refresh token을 서버에서 무효화한다.
  • 401, 403, 419 같은 상태 코드를 팀 규칙으로 정한다.
  • CSRF token, SameSite, Origin/Referer 검증 중 어떤 방식을 쓸지 정한다.

개인 프로젝트 기준

  • Cookie 기반 인증은 처음 설정이 까다롭지만 운영에 가면 더 안전한 기본값이 된다.
  • 로컬 개발에서는 proxy를 사용해 same-origin처럼 맞추면 Cookie 디버깅이 쉬워진다.
  • refresh 실패 시 query cache와 사용자 상태를 비우고 로그인 화면으로 보낸다.
  • multi-tab logout은 storage event나 BroadcastChannel을 검토한다.

실전 팁

  • XSS와 CSRF는 서로 다른 공격이다. Cookie만 쓴다고 XSS가 없어지는 것도 아니고, localStorage만 쓴다고 CSRF가 사라지는 것도 아니다.
  • 사용자 입력 HTML 렌더링은 최대한 피하고, 필요하면 sanitizer를 사용한다.
  • 인증 상태는 UI 편의상 cache할 수 있지만, 권한 판단은 API 응답을 신뢰한다.
  • access token을 메모리에만 두는 설계는 탈취면에서 장점이 있지만, 새로고침 복구와 다중 탭 동기화를 별도로 풀어야 한다.
  • 파일 업로드처럼 body를 재사용하기 어려운 요청은 401 후 자동 재시도 정책을 별도로 검토한다.
  • 관련 판단은 401이 났을 때 자동 재발급은 어디서 처리해야 할까?를 본다.

위험 신호!

  • refresh endpoint가 401을 반환했는데 interceptor가 다시 refresh를 시도한다.
  • refresh token이 localStorage에 오래 저장된다.
  • 401과 403을 구분하지 않아 사용자를 계속 로그인 화면으로 보낸다.
  • logout 후 다른 탭은 여전히 로그인된 것처럼 보인다.

확인 질문

확인 질문

  • XSS 상황에서 localStorage token이 위험한 이유는 무엇인가?
    • 공격자 스크립트가 JavaScript로 token을 읽어 탈취할 수 있기 때문이다.
  • Cookie 기반 인증에서 CSRF를 고려해야 하는 이유는 무엇인가?
    • Cookie는 조건이 맞으면 요청에 자동 첨부되므로 사용자가 의도하지 않은 요청에도 인증 정보가 실릴 수 있기 때문이다.
  • refresh 중복 호출을 막아야 하는 이유는 무엇인가?
    • 여러 API가 동시에 401을 받으면 refresh 요청이 폭주하거나 token rotation 충돌이 생길 수 있기 때문이다.

참고 문서