junyeokk
Blog
Security·2024. 11. 13

cookie-parser

Express에서 클라이언트가 보내는 쿠키를 사용하려면 Cookie 헤더를 직접 파싱해야 한다. 요청 헤더의 Cookie 값은 단순한 문자열이다.

Cookie: sessionId=abc123; theme=dark; lang=ko

이걸 직접 파싱하려면 세미콜론으로 분리하고, 각 쌍을 =으로 나누고, URL 디코딩까지 해야 한다. 간단해 보이지만 엣지 케이스가 많다. 값에 =이 포함된 경우, URL 인코딩된 값, 서명된 쿠키 등을 모두 처리하려면 코드가 금방 복잡해진다.

javascript
Cookie: sessionId=abc123; theme=dark; lang=ko

cookie-parser는 이 파싱 과정을 미들웨어로 추상화한다. 한 줄 등록하면 req.cookies로 파싱된 쿠키 객체에 접근할 수 있다.

기본 사용법

javascript
// 직접 파싱하면 이런 식
const cookies = {};
req.headers.cookie?.split(';').forEach(cookie => {
  const [name, ...rest] = cookie.trim().split('=');
  cookies[name] = decodeURIComponent(rest.join('='));
});

cookieParser()를 미들웨어로 등록하면 모든 요청에서 Cookie 헤더가 자동으로 파싱되어 req.cookies 객체에 담긴다. 키-값 형태이므로 req.cookies.키이름으로 바로 접근할 수 있다.

서명된 쿠키 (Signed Cookies)

일반 쿠키는 클라이언트가 자유롭게 수정할 수 있다. 브라우저 개발자 도구에서 쿠키 값을 바꾸면 서버는 변조된 값을 그대로 읽게 된다. 세션 ID나 사용자 식별 정보가 담긴 쿠키라면 이건 보안 문제다.

서명된 쿠키는 이 문제를 해결한다. 쿠키 값에 HMAC 서명을 붙여서 서버가 "이 값을 내가 설정한 게 맞는지" 검증할 수 있게 한다. 클라이언트가 값을 변조하면 서명 검증에 실패해서 서버가 이를 감지할 수 있다.

동작 원리

원본 값: abc123 서명 생성: HMAC-SHA256("abc123", secret) → "x7kf9..." 저장되는 쿠키 값: s:abc123.x7kf9...

쿠키 값 앞에 s: 접두어가 붙고, 원본 값 뒤에 .으로 구분된 서명이 추가된다. 서버가 이 쿠키를 읽을 때 서명을 다시 계산해서 일치하는지 확인한다. 값이 변조되었으면 서명이 달라지므로 req.signedCookies에서 해당 값이 false로 나온다.

사용법

javascript
const express = require('express');
const cookieParser = require('cookie-parser');

const app = express();
app.use(cookieParser());

app.get('/profile', (req, res) => {
  console.log(req.cookies);
  // { sessionId: 'abc123', theme: 'dark', lang: 'ko' }
  
  const sessionId = req.cookies.sessionId;
  res.json({ session: sessionId });
});

주의할 점: 서명된 쿠키는 req.cookies가 아니라 req.signedCookies에서 읽어야 한다. 두 객체는 분리되어 있다. req.cookies에는 서명되지 않은 일반 쿠키만 들어가고, req.signedCookies에는 서명이 검증된 쿠키만 들어간다. 서명 검증에 실패한 쿠키는 값이 false가 된다.

secret 키 관리

javascript
원본 값: abc123
서명 생성: HMAC-SHA256("abc123", secret) → "x7kf9..."
저장되는 쿠키 값: s:abc123.x7kf9...

배열로 여러 키를 전달하면 첫 번째 키로 새 쿠키에 서명하고, 나머지 키들은 기존 쿠키 검증용으로 사용한다. 이렇게 하면 secret 키를 교체할 때 기존 사용자의 쿠키가 한꺼번에 무효화되는 걸 방지할 수 있다. 새 키를 배열 앞에 추가하고, 충분한 시간이 지나면 이전 키를 제거하면 된다.

서명 vs 암호화

서명된 쿠키는 변조 감지를 위한 것이지, 값 은닉을 위한 게 아니다. 서명된 쿠키 값 s:abc123.x7kf9...에서 원본 값 abc123은 그대로 보인다. 누구나 읽을 수 있다. 서명은 단지 이 값이 서버가 설정한 원본 그대로인지를 보장할 뿐이다.

민감한 정보를 쿠키에 직접 저장해야 한다면 별도로 암호화해야 한다. 하지만 대부분의 경우 쿠키에는 세션 ID 같은 식별자만 저장하고 실제 데이터는 서버(DB나 Redis)에 보관하는 게 올바른 설계다. 이렇게 하면 쿠키 값이 노출되더라도 그 자체로는 의미 있는 정보가 아니다.

쿠키 옵션

res.cookie()로 쿠키를 설정할 때 다양한 옵션을 지정할 수 있다. cookie-parser가 직접 관여하는 건 아니지만 (Express 내장 기능), 쿠키를 다룰 때 함께 알아야 하는 옵션들이다.

javascript
// secret 키를 전달해서 서명 기능 활성화
app.use(cookieParser('my-secret-key'));

// 서명된 쿠키 설정
app.post('/login', (req, res) => {
  res.cookie('sessionId', 'user-session-abc', {
    signed: true,       // 서명 활성화
    httpOnly: true,
    maxAge: 3600000     // 1시간
  });
  res.json({ message: 'logged in' });
});

// 서명된 쿠키 읽기
app.get('/dashboard', (req, res) => {
  // 서명된 쿠키는 req.signedCookies에서 접근
  const sessionId = req.signedCookies.sessionId;
  
  if (!sessionId) {
    // sessionId가 없거나 서명 검증 실패
    return res.status(401).json({ error: 'unauthorized' });
  }
  
  res.json({ session: sessionId });
});

httpOnly

true로 설정하면 브라우저의 JavaScript에서 document.cookie로 해당 쿠키에 접근할 수 없다. XSS(Cross-Site Scripting) 공격으로 쿠키를 탈취하는 것을 방지한다. 인증 관련 쿠키는 반드시 httpOnly: true로 설정해야 한다.

secure

true로 설정하면 HTTPS 연결에서만 쿠키가 전송된다. HTTP로 접속하면 브라우저가 쿠키를 서버로 보내지 않는다. 프로덕션 환경에서 인증 쿠키에는 필수적인 옵션이다. 로컬 개발 시에는 HTTP를 쓰므로 환경에 따라 분기하는 게 일반적이다.

javascript
// 단일 키
app.use(cookieParser('my-secret'));

// 배열로 여러 키 (키 로테이션)
app.use(cookieParser(['new-secret', 'old-secret']));

sameSite

CSRF(Cross-Site Request Forgery) 공격을 방지하는 옵션이다. 다른 사이트에서 우리 서버로 요청을 보낼 때 쿠키를 포함할지 결정한다.

동작
'strict'같은 사이트 요청에서만 쿠키 전송. 외부 링크로 접속해도 쿠키가 안 붙음
'lax'GET 같은 안전한 요청에서는 쿠키 전송. POST 등에서는 미전송. 기본값
'none'항상 쿠키 전송. 반드시 secure: true와 함께 사용해야 함

'strict'는 가장 안전하지만 사용자 경험에 영향을 줄 수 있다. 예를 들어 이메일의 링크를 클릭해서 사이트에 접속하면 쿠키가 안 붙어서 로그인이 풀린 것처럼 보인다. 'lax'는 이 문제를 완화하면서도 POST 기반 CSRF는 막아준다.

maxAge vs expires

javascript
res.cookie('token', 'value', {
  httpOnly: true,     // JavaScript에서 접근 불가
  secure: true,       // HTTPS에서만 전송
  sameSite: 'strict', // 크로스사이트 요청에 쿠키 미포함
  maxAge: 86400000,   // 밀리초 단위 만료 시간
  path: '/',          // 쿠키가 유효한 경로
  domain: '.example.com', // 쿠키가 유효한 도메인
  signed: true        // 서명 여부
});

maxAge가 더 직관적이라 보통 이걸 쓴다. 둘 다 지정하면 maxAge가 우선한다. 둘 다 없으면 세션 쿠키가 되어 브라우저를 닫으면 사라진다.

쿠키 삭제

javascript
res.cookie('token', value, {
  secure: process.env.NODE_ENV === 'production',
  // ...
});

res.clearCookie()를 사용한다. 중요한 점은 쿠키를 설정할 때 사용한 옵션과 동일한 옵션을 전달해야 한다는 것이다. path, domain, httpOnly, secure, sameSite 등이 다르면 브라우저가 다른 쿠키로 인식해서 삭제가 안 된다. 내부적으로는 해당 쿠키의 만료 시간을 과거로 설정하는 방식으로 동작한다.

JSON 쿠키

cookie-parser는 값이 j:로 시작하면 JSON으로 파싱한다.

javascript
// maxAge: 밀리초 단위 (상대 시간)
res.cookie('session', 'abc', { maxAge: 3600000 }); // 1시간 후 만료

// expires: Date 객체 (절대 시간)
res.cookie('session', 'abc', { 
  expires: new Date(Date.now() + 3600000) 
});

Express의 res.cookie()에 객체를 전달하면 자동으로 j: 접두어가 붙어서 저장된다. cookie-parser는 이 접두어를 감지해서 자동으로 JSON.parse()를 수행한다.

NestJS에서 사용

NestJS는 내부적으로 Express(또는 Fastify)를 사용하므로 cookie-parser를 동일하게 적용할 수 있다.

typescript
app.post('/logout', (req, res) => {
  res.clearCookie('sessionId', {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    signed: true
  });
  res.json({ message: 'logged out' });
});

main.ts에서 app.use()로 등록하면 모든 컨트롤러에서 @Req() 데코레이터를 통해 쿠키에 접근할 수 있다.

typescript
// 설정
res.cookie('preferences', { theme: 'dark', lang: 'ko' });
// 실제 저장: j:{"theme":"dark","lang":"ko"} (URL 인코딩됨)

// 읽기
console.log(req.cookies.preferences);
// { theme: 'dark', lang: 'ko' } — 자동으로 객체로 파싱됨

Guard에서 쿠키 기반 인증을 처리할 수도 있다. 세션 쿠키를 검증하는 Guard를 만들면 인증 로직을 컨트롤러에서 분리할 수 있다.

typescript
import * as cookieParser from 'cookie-parser';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(cookieParser('my-secret'));
  await app.listen(3000);
}

cookie-parser의 코드는 약 60줄 정도로 매우 작다. 핵심 동작을 정리하면:

  1. Cookie 헤더 문자열을 cookie 모듈의 parse() 함수로 파싱 (세미콜론 분리, URL 디코딩)
  2. 파싱된 각 쿠키 값을 순회하면서:
    • j: 접두어 → JSON 파싱해서 객체로 변환
    • s: 접두어 + secret 키 존재 → 서명 검증 후 req.signedCookies에 추가
    • 그 외 → req.cookies에 추가
  3. req.cookiesreq.signedCookies를 요청 객체에 설정

실질적인 파싱 작업은 cookie 패키지가 담당하고, cookie-parser는 서명 검증과 JSON 파싱을 추가로 수행하는 래퍼 역할이다.

정리

기능설명
기본 파싱Cookie 헤더 → req.cookies 객체
서명 쿠키secret 키로 HMAC 서명/검증, req.signedCookies
JSON 쿠키j: 접두어 감지, 자동 파싱
키 로테이션secret 배열로 점진적 키 교체

cookie-parser 자체는 단순한 파싱 미들웨어이지만, 쿠키 기반 인증을 구현할 때 쿠키 옵션(httpOnly, secure, sameSite)과 서명된 쿠키의 동작 원리를 이해하는 것이 중요하다. 특히 clearCookie할 때 설정 시와 동일한 옵션을 전달해야 하는 점은 실수하기 쉬운 부분이니 주의해야 한다.