Krong Dev.
Backend Nest.js Security Cookie

NestJS 스터디 7주차 회고 — localStorage에서 HttpOnly 쿠키로

프론트엔드 연동 과정에서 발견한 로그인 보안 설계의 허점과, HttpOnly 쿠키 기반으로 전환하면서 Secure, SameSite, CORS 세 레이어를 직접 구현한 흐름을 정리합니다.

NestJS 스터디 7주차 회고 — localStorage에서 HttpOnly 쿠키로

지난 주에는 TodoList를 서버로 구현하면서 부딪혔던 벽에 대한 얘기를 했습니다.

이번 주는 실제 프론트엔드와의 연동에서 어떤 로그인 보안 설계의 허점이 있었고, 어떻게 해결했는지에 대한 얘기를 풀어나가보려 합니다.

CORS 에러를 백엔드에 해결해달라고만 했지, 어떻게 해결해야 할까?

비슷한 보안 문제는 또 어떤 것들이 있을까?

가장 처음 맞닥뜨렸을 때, 문제의 원인은 단순해 보였습니다. 쿠키를 요청에 포함하는 옵션이 빠져있었습니다.

실행 결과

수정 전 코드입니다. 토큰 값이 localStorage에 그대로 저장되고, 요청 인터셉터에서 Authorization 헤더로 직접 붙여 보내는 구조입니다.

// apps/web/src/lib/api/client.ts (수정 전 — CORS 에러 발생)
export const apiClient = axios.create({
  baseURL: (import.meta.env.VITE_API_URL as string | undefined) ?? "/api/v1",
  headers: { "Content-Type": "application/json" },
  // withCredentials 없음 -> 브라우저가 Set-Cookie 무시
});

apiClient.interceptors.request.use((config) => {
  const token = localStorage.getItem("accessToken");
  if (token) {
    config.headers.Authorization = `Bearer ${token}`; // 토큰을 헤더로 직접 붙임
  }
  return config;
});

수정 후 코드입니다. withCredentials: true 한 줄이 추가되고, 인터셉터 자체가 사라졌습니다.

// apps/web/src/lib/api/client.ts (수정 후)
export const apiClient = axios.create({
  baseURL: (import.meta.env.VITE_API_URL as string | undefined) ?? "/api/v1",
  headers: { "Content-Type": "application/json" },
  withCredentials: true, // 크로스 오리진 요청에서도 쿠키 전송
});
// request interceptor 자체가 사라짐 -> 쿠키는 브라우저가 알아서 붙임

withCredentials: true만 추가하면 끝인 줄 알았는데, 그게 아니었습니다.

이 글에서는 CORS 에러를 파고들면서 localStorage 기반 토큰 저장의 구조적 문제를 발견하고, HttpOnly 쿠키로 전환하면서 세 가지 보안 레이어를 직접 구현한 흐름을 정리합니다.


CORS 에러가 보안 설계의 문제였다

withCredentials: true를 추가하니 CORS 에러는 사라졌습니다. 그런데 그 과정에서 더 근본적인 문제가 보였습니다. 토큰을 localStorage에 저장하고 있었다는 것입니다.

localStorage가 왜 문제인가

기존 코드에서는 로그인 성공 시 액세스 토큰을 localStorage에 저장하고, 매 요청마다 Authorization 헤더에 직접 붙여서 보내는 구조였습니다.

하지만, localStorage는 JavaScript로 읽을 수 있어서 XSS 공격에 취약합니다.

XSS(Cross-Site Scripting)는 악성 스크립트를 페이지에 심어서 localStorage.getItem()을 호출하는 공격으로, localStorage는 이런 공격에 대한 방어 수단이 없습니다. XSS가 발생하면 localStorage의 토큰은 무조건 탈취됩니다.

수정 전 : 토큰 값이 localStorage에 그대로 저장됩니다.

// apps/web/src/shared/stores/authStore.ts (수정 전)
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
  accessToken: localStorage.getItem("accessToken"), // JS로 읽기 가능 -> XSS 취약
  setToken: (token) => {
    localStorage.setItem("accessToken", token);
    set({ accessToken: token });
  },
  clearToken: () => {
    localStorage.removeItem("accessToken");
    set({ accessToken: null });
  },
}));

HttpOnly 쿠키로 전환

HttpOnly 플래그가 붙은 쿠키는 JavaScript에서 읽을 수 없습니다. document.cookie로 접근하려 해도 해당 쿠키는 보이지 않습니다. 브라우저가 HTTP 요청을 보낼 때 자동으로 실어 보낼 뿐입니다. 토큰 값 자체를 클라이언트 스토어에 보관할 필요가 없어집니다. “로그인 상태인가/아닌가”만 클라이언트가 알면 충분합니다. 서버는 로그인 성공 시 토큰을 응답 body에 포함하지 않고, Set-Cookie 헤더로 HttpOnly 쿠키를 내려보냅니다.

수정 후 : 클라이언트 스토어는 토큰 값 자체는 저장하지 않습니다.

// apps/web/src/shared/stores/authStore.ts (수정 후)
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
  isLoggedIn: false, // 토큰 값 자체는 저장 안 함 -> XSS 취약
  setLoggedIn: (value) => set({ isLoggedIn: value }),
}));

로그인 시 : HttpOnly 쿠키로 토큰을 발급합니다. 응답 body에는 토큰이 없습니다.

// apps/server/src/auth-accounts/auth-accounts.controller.ts
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
async login(
  @Body() dto: LoginRequestDto,
  @Res({ passthrough: true }) res: Response,
): Promise<{ ok: true }> {
  const accessToken = await this.authAccountsService.login(dto);
  res.cookie('access_token', accessToken, {
    httpOnly: true,                                    // JS 접근 불가
    secure: process.env.NODE_ENV === 'production',    // HTTPS에서만 전송
    sameSite: 'lax',                                  // CSRF 기본 차단
    path: '/',
    maxAge: 1000 * 60 * 60 * 24,                     // 24시간
  });
  return { ok: true }; // 토큰을 응답 body에 포함하지 않음 -> XSS 취약
}

서버 : JWT를 Authorization 헤더가 아닌 쿠키에서 추출합니다.

// apps/server/src/auth-accounts/jwt.strategy.ts
// ...
super({
  jwtFromRequest: ExtractJwt.fromExtractors([
    (req: Request) => req?.cookies?.access_token ?? null, // 쿠키에서 읽음
  ]),
  secretOrKey: config.secret,
});
// ...
async validate(payload: JwtPayload) {
  const user = await this.userRepository.findOne({
    where: { userId: payload.sub },
  });
  if (!user || !user.isActive) {
    throw new UnauthorizedException();
  }
  return user;
}

쿠키로 전환하고 나니, 클라이언트는 토큰의 존재를 모르는 상태가 됐습니다. 인증은 브라우저와 서버 사이에서 자동으로 처리되고, 클라이언트는 로그인 상태만 알면 충분해졌습니다.


쿠키 하나로 끝이 아니다

세 가지 레이어 설정

HttpOnly 쿠키로 XSS 공격의 토큰 탈취는 막았습니다. 하지만 쿠키는 그 자체로 다른 공격 경로가 있어서, 세 가지 설정을 추가로 적용해야 했습니다.

Secure - HTTPS에서만 쿠키를 전송한다

쿠키는 기본적으로 HTTP 요청에도 실려 갑니다. 공용 와이파이처럼 암호화되지 않은 네트워크에서 패킷이 탈취되면 쿠키가 그대로 노출됩니다. Secure 플래그를 달면 HTTPS 연결에서만 쿠키가 전송됩니다. 로컬 개발 환경은 HTTP라서, 개발 환경에서는 false, 프로덕션에서만 true가 되도록 조건부로 설정합니다.

프로덕션 환경에서만 Secure를 활성화합니다.

// 개발 환경에서는 false, 프로덕션에서는 HTTPS 전송만 허용
secure: process.env.NODE_ENV === 'production',

실행 결과

브라우저 DevTools Application 탭에서 access_token 쿠키에 HttpOnly(Ht…) 체크와 SameSite: Lax 설정이 찍힌 것을 직접 확인할 수 있습니다.


SameSite: lax - 외부 사이트의 요청을 제한한다

SameSite 쿠키는 서버가 크로스 사이트 요청과 함께 쿠키가 전송되는 것을 제한할 수 있게 해줍니다.
- 출처: MDN

CSRF(Cross-Site Request Forgery)는 악성 사이트(evil.com)에서 사용자의 브라우저를 통해 myapp.com으로 요청을 보내는 공격입니다.


브라우저는 myapp.com의 쿠키를 요청에 자동으로 실어 보내기 때문에, 서버 입장에서는 정상 요청처럼 보입니다.

SameSite: lax는 외부 사이트에서 링크를 클릭해서 오는 GET 탐색은 허용하지만, 외부 사이트의 form POST나 fetch 요청에는 쿠키를 포함하지 않습니다.
대부분의 CSRF 공격이 POST 방식이라 lax만으로 실용적인 방어가 됩니다.

strict는 외부 사이트에서의 GET 탐색도 막아서 일반 서비스에서는 사용성이 너무 떨어집니다.
none은 크로스 사이트 요청 전체에 쿠키를 허용하되, 반드시 Secure와 함께 써야 합니다.

GET (링크)POST (폼/fetch)
strictXX
laxOX
noneOO (Secure 필수)

현재 프로젝트에 적용한 설정입니다.

// 외부 사이트에서 링크 클릭 -> 쿠키 전송 O
// 외부 사이트 form POST, fetch -> 쿠키 전송 X (CSRF 기본 차단)
sameSite: 'lax',

CORS + credentials - 허용된 출처에서만 쿠키 포함 요청을 받는다

브라우저는 기본적으로 다른 출처(origin)로의 요청에 쿠키를 포함하지 않습니다.
프론트엔드(localhost:5173)에서 백엔드(localhost:3000)로 요청할 때 쿠키가 안 가는 게 기본 동작입니다.

서버에 credentials: true, 클라이언트에 withCredentials: true 한 쌍이 있어야 쿠키가 요청에 실려 갑니다.

credentials: true를 설정하면 CORS의 origin에 와일드카드(*)를 쓸 수 없기 때문에 반드시 허용할 출처를 명시해야 합니다.
“아무 출처에서나 쿠키 포함 요청을 허용”하면 보안이 없는 것이기 때문입니다.

cookie-parser 미들웨어 등록도 필수입니다.
요청의 Cookie 헤더를 req.cookies로 파싱해야 JwtStrategy가 쿠키를 읽을 수 있습니다.

서버 CORS 설정 — 허용 출처를 명시하고 credentials를 활성화합니다.

// apps/server/src/main.ts
app.enableCors({
  origin: ["http://localhost:5173"], // 와일드카드(*) 불가 -> credentials와 함께 쓸 수 없음
  credentials: true, // Set-Cookie 헤더 허용
});
app.use(cookieParser()); // 요청의 Cookie 헤더를 req.cookies로 파싱

클라이언트 — withCredentials: true로 쿠키 포함 요청을 활성화합니다.

// apps/web/src/lib/api/client.ts
export const apiClient = axios.create({
  // ...
  withCredentials: true, // 요청 시 쿠키 포함, 응답의 Set-Cookie 저장
});

글로벌 가드와 @Public() 패턴 - 보호할 엔드포인트와 공개 엔드포인트를 나눈다

쿠키 기반 인증이 완성되면, 모든 엔드포인트에 JWT 가드를 적용하는 게 자연스럽습니다.

하지만 로그인과 회원가입 엔드포인트 자체는 인증 없이 접근 가능해야 합니다. 글로벌 가드를 등록하되, 특정 엔드포인트에만 @Public() 데코레이터를 달아서 인증을 건너뛰는 패턴이 깔끔합니다. 가드 내부에서 Reflector@Public() 여부를 확인하고, 붙어있으면 검증 없이 통과시킵니다.

글로벌 가드 — @Public() 데코레이터가 없으면 모든 요청에 JWT 검증을 적용합니다.

// apps/server/src/auth-accounts/guards/jwt-auth.guard.ts
canActivate(context: ExecutionContext) {
  const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
    context.getHandler(),
    context.getClass(),
  ]);
  if (isPublic) return true;          // @Public() -> 인증 건너뜀
  return super.canActivate(context);  // 나머지 -> JWT 검증
}

@Public() 데코레이터 — 인증 없이 접근 가능한 엔드포인트에 붙입니다.

// apps/server/src/common/decorators/public.decorator.ts
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

// 사용 예
@Public()
@Post('login')   // 인증 없이 접근 가능

지금까지 적용된 보안 설정을 정리하면 다음과 같습니다.

항목상태비고
HttpOnly 쿠키로 토큰 저장access_token 쿠키
Secure (HTTPS only)NODE_ENV === 'production' 조건부
SameSitelax
Path/
MaxAge24시간
CORS credentialscredentials: true, origin 화이트리스트
cookie-parser미들웨어 등록됨
로그아웃 (쿠키 제거)clearCookie
프론트 withCredentials: true연동 완료

지금 구조로 막을 수 없는 것들

세 가지 레이어를 적용하고 나면 기본적인 보안은 갖춰진 상태입니다. 하지만 직접 구현하면서 ‘이 구조로는 막을 수 없는 것들’도 함께 보였습니다.

리프레시 토큰 없음 — 토큰 수명이 24시간 고정

현재 구조는 액세스 토큰 하나만 있고 수명이 24시간입니다. 토큰이 탈취되면 24시간 동안 공격자가 API를 호출할 수 있습니다. 실무에서는 액세스 토큰 수명을 15분~30일까지 짧게 가져가고, 만료 시 자동 재발급하는 구조가 일반적입니다. 리프레시 토큰은 액세스 토큰보다 훨씬 좁은 경로(path: /auth/refresh)에만 전송되도록 쿠키를 분리해서 노출 범위를 최소화합니다.

현재 단일 토큰, 단일 수명 구조입니다.

// apps/server/src/auth-accounts/auth-accounts.module.ts
// ...
signOptions: {
  expiresIn: config.expiresIn, // 단일 토큰, 단일 수명
},
// ...
토큰수명저장 위치용도
액세스15분~1시간HttpOnly 쿠키 or 메모리API 인증
리프레시7~30일HttpOnly 쿠키 (path: /auth/refresh)액세스 토큰 재발급만

토큰 무효화(Revocation) 없음 — 로그아웃이 서버에 기록되지 않는다

현재 로그아웃은 클라이언트 쿠키를 지우는 것으로 끝납니다. 서버에는 아무 기록도 남지 않습니다. 쿠키가 지워진 클라이언트에서는 인증이 안 되지만, 탈취된 토큰을 가진 공격자는 만료 시간 전까지 API를 계속 호출할 수 있습니다. 완전한 무효화를 위해서는 Redis 블랙리스트나 DB 세션 테이블이 필요합니다. 로그아웃 시 토큰을 블랙리스트에 저장하고, 매 요청마다 블랙리스트를 확인하는 구조입니다.

현재 로그아웃 구현 — 클라이언트 쿠키만 삭제하고 서버에는 기록이 없습니다.

// apps/server/src/auth-accounts/auth-accounts.controller.ts
logout(@Res({ passthrough: true }) res: Response): { ok: true } {
  res.clearCookie('access_token', { ... }); // 클라이언트 쿠키만 삭제
  return { ok: true };
  // 서버에는 아무 기록도 남지 않음
  // -> 탈취된 토큰은 만료 전까지 여전히 유효
}

블랙리스트 패턴 — 현재 미구현, 이런 구조로 대응할 수 있습니다.

// 블랙리스트 패턴 (현재 미구현)
async logout(token: string) {
  const decoded = this.jwtService.decode(token);
  const ttl = decoded.exp - Math.floor(Date.now() / 1000);
  await this.redis.set(`blacklist:${token}`, '1', 'EX', ttl);
}

// jwt.strategy.ts validate() 에서 확인
async validate(payload: JwtPayload) {
  const isBlacklisted = await this.redis.get(`blacklist:${token}`);
  if (isBlacklisted) throw new UnauthorizedException();
  // ...
}

CSRF 대응이 완전하지 않다

SameSite: lax가 대부분의 CSRF를 막지만, 완전한 대응은 아닙니다. 서로 다른 도메인에서 SameSite: none이 필요한 환경이나 구형 브라우저는 SameSite를 지원하지 않아서, 이 경우 CSRF 토큰을 추가하는 게 완전한 방어입니다. 현재 구조(같은 도메인, lax)라면 실용적으로는 충분하지만, 도메인이 달라지는 순간 다시 고려해야 합니다.

결론적으로 현재 구조는 학습과 개인 프로젝트 수준에서는 충분하지만, 실무로 가면 리프레시 토큰 분리, 토큰 무효화, CSRF 토큰 추가가 차례로 필요해집니다.


마치며

CORS 에러를 파고들었더니 localStorage 기반 토큰 저장이 구조적으로 XSS에 취약하다는 것을 알았고, HttpOnly 쿠키로 전환하면서 Secure·SameSite·CORS 세 가지 레이어를 직접 적용했습니다.

직접 구현하면서 세 가지가 확실히 와닿았습니다.

  • localStorage는 XSS에 무방비입니다. HttpOnly 쿠키는 JavaScript에서 접근할 수 없어서, XSS가 발생해도 토큰을 가져갈 수 없습니다.
  • Secure·SameSite·CORS는 각각 독립된 설정이 아니라, HttpOnly 쿠키를 전제로 작동하는 하나의 보안 레이어입니다. 셋 중 하나라도 빠지면 다른 경로가 열립니다.
  • 현재 구조로 막을 수 없는 것들(리프레시 토큰, 토큰 무효화, CSRF 완전 대응)을 알고 있는 것과 모르는 것은 다릅니다. 다음 단계가 보이는 상태로 멈추는 게, 모르고 멈추는 것보다 낫습니다.

다음 글에서는 다음 글에서는 서버 에러를 프론트가 읽을 수 있게 — 예외 처리, 커스텀 필터, 공통 응답 설계 과정을 정리할 예정입니다.