쿠키 기반 인증은 웹 애플리케이션에서 가장 널리 사용되는 사용자 인증 방식 중 하나이다. 이 방식은 HTTP 프로토콜의 무상태(stateless) 특성을 극복하고 사용자의 로그인 상태를 유지하기 위한 메커니즘으로, 오랜 시간 동안 웹의 핵심 인증 기술로 자리잡아 왔다.

쿠키의 기본 개념

쿠키(Cookie)는 웹 서버가 사용자의 브라우저에 저장하는 작은 데이터 조각이다. 이 데이터는 브라우저가 서버에 요청을 보낼 때마다 함께 전송되어, 서버가 클라이언트를 식별할 수 있게 해준다. 쿠키는 원래 Lou Montulli가 1994년 Netscape Communications에서 개발했으며, 상태를 유지하지 않는 HTTP 프로토콜에 상태 정보를 추가하기 위한 목적으로 설계되었다.

쿠키 기반 인증의 작동 원리

쿠키 기반 인증의 기본 흐름은 다음과 같다:

  1. 사용자 로그인: 사용자가 자신의 자격 증명(일반적으로 사용자 이름과 비밀번호)을 서버에 전송한다.
  2. 자격 증명 검증: 서버는 제공된 자격 증명을 검증한다. 일반적으로 비밀번호는 데이터베이스에 해시된 형태로 저장되어 있으며, 서버는 입력된 비밀번호를 해시하여 저장된 해시와 비교한다.
  3. 세션 생성: 인증이 성공하면 서버는 고유한 세션 ID를 생성하고, 이 ID를 사용자 정보와 연결하여 서버의 메모리, 데이터베이스, 또는 캐시(Redis, Memcached 등)에 저장한다.
  4. 쿠키 설정: 서버는 HTTP 응답 헤더의 ‘Set-Cookie’ 지시자를 사용하여 세션 ID를 클라이언트에게 전송한다. 이 쿠키는 브라우저에 저장된다.
  5. 후속 요청: 사용자가 추가 요청을 할 때마다 브라우저는 자동으로 쿠키를 요청 헤더에 포함시켜 서버에 전송한다.
  6. 세션 검증: 서버는 요청에 포함된 세션 ID를 검증하고, 유효한 세션에 해당하는 사용자 정보를 검색하여 해당 요청을 처리한다.
  7. 로그아웃/세션 종료: 사용자가 로그아웃하거나 세션이 만료되면, 서버는 세션 저장소에서 세션 정보를 제거하고, 클라이언트의 쿠키를 무효화할 수 있다.

쿠키 속성과 옵션

쿠키 기반 인증을 구현할 때, 다음과 같은 중요한 쿠키 속성들을 고려해야 한다:

  1. 필수 속성

    • Name=Value: 쿠키의 이름과 값을 지정한다.
    • 세션 인증에서는 일반적으로 이름이 ‘sessionid’나 ‘connect.sid’ 등으로, 값은 암호화된 세션 식별자로 설정된다.
  2. 보안 관련 속성

    • Secure:
      • 이 플래그가 설정되면 쿠키는 HTTPS 연결을 통해서만 전송된다.
      • 이는 중간자 공격(MITM)으로부터 쿠키를 보호하는 데 중요하다.
    • HttpOnly:
      • 이 플래그가 설정되면 클라이언트측 JavaScript로는 쿠키에 접근할 수 없게 된다.
      • 이는 크로스 사이트 스크립팅(XSS) 공격으로부터 보호하는 데 중요하다.
    • SameSite:
      • 이 속성은 쿠키가 크로스 사이트 요청과 함께 전송되는 방식을 제어한다.
      • ‘Strict’, ‘Lax’, ‘None’ 값 중 하나를 가질 수 있으며, 크로스 사이트 요청 위조(CSRF) 공격을 방지하는 데 도움이 된다.
  3. 생명주기 관련 속성

    • Expires/Max-Age:
      • 쿠키의 수명을 정의한다.
      • Expires는 절대적인 만료 날짜를 설정하고, Max-Age는 쿠키가 유효한 초 단위 시간을 지정한다.
      • 설정하지 않으면 쿠키는 ‘세션 쿠키’가 되어 브라우저가 닫힐 때 삭제된다.
  4. 범위 관련 속성

    • Domain:
      • 쿠키가 전송될 도메인을 지정한다.
      • 설정하지 않으면 쿠키를 설정한 도메인(서브도메인 제외)에서만 유효한다.
    • Path:
      • 쿠키가 유효한 URL 경로를 지정한다.
      • 기본값은 ‘/‘로, 모든 경로에 대해 쿠키가 전송된다.

쿠키 기반 인증의 구현 예시

서버 측 구현 (Node.js/Express)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
const express = require('express');
const session = require('express-session');
const bcrypt = require('bcrypt');
const app = express();

// 세션 미들웨어 설정
app.use(session({
  secret: '안전한_랜덤_문자열', // 세션 데이터 서명용 비밀 키
  resave: false,             // 세션이 변경되지 않아도 저장할지 여부
  saveUninitialized: false,  // 초기화되지 않은 세션도 저장할지 여부
  cookie: {
    httpOnly: true,          // JavaScript에서 쿠키 접근 방지
    secure: process.env.NODE_ENV === 'production', // HTTPS에서만 쿠키 전송
    maxAge: 1000 * 60 * 60 * 24, // 쿠키 유효 기간 (1일)
    sameSite: 'lax'          // CSRF 방지를 위한 SameSite 설정
  }
}));

// 사용자 로그인 처리
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  
  // 사용자 정보 조회 (실제로는 데이터베이스에서 조회)
  const user = await findUserByUsername(username);
  
  if (!user) {
    return res.status(401).json({ error: '사용자를 찾을 수 없습니다' });
  }
  
  // 비밀번호 검증
  const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
  
  if (!isPasswordValid) {
    return res.status(401).json({ error: '비밀번호가 일치하지 않습니다' });
  }
  
  // 세션에 사용자 정보 저장 (민감한 정보 제외)
  req.session.user = {
    id: user.id,
    username: user.username,
    role: user.role
  };
  
  // 로그인 성공 응답
  res.json({ success: true, user: req.session.user });
});

// 인증된 사용자만 접근할 수 있는 라우트 보호
function requireAuth(req, res, next) {
  if (!req.session.user) {
    return res.status(401).json({ error: '인증이 필요합니다' });
  }
  next();
}

// 보호된 라우트 예시
app.get('/profile', requireAuth, (req, res) => {
  res.json({ user: req.session.user });
});

// 로그아웃 처리
app.post('/logout', (req, res) => {
  req.session.destroy(err => {
    if (err) {
      return res.status(500).json({ error: '로그아웃 처리 중 오류가 발생했습니다' });
    }
    res.clearCookie('connect.sid'); // 세션 쿠키 삭제
    res.json({ success: true });
  });
});

app.listen(3000, () => {
  console.log('서버가 3000번 포트에서 실행 중입니다');
});

클라이언트 측 구현 (JavaScript)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 로그인 요청 함수
async function login(username, password) {
  try {
    const response = await fetch('/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ username, password }),
      credentials: 'same-origin' // 쿠키가 요청과 함께 전송되도록 설정
    });
    
    const data = await response.json();
    
    if (!response.ok) {
      throw new Error(data.error || '로그인에 실패했습니다');
    }
    
    // 로그인 성공 처리
    console.log('로그인 성공:', data.user);
    return data.user;
  } catch (error) {
    console.error('로그인 오류:', error);
    throw error;
  }
}

// 보호된 라우트 데이터 요청 함수
async function fetchProfile() {
  try {
    const response = await fetch('/profile', {
      credentials: 'same-origin' // 쿠키가 요청과 함께 전송되도록 설정
    });
    
    if (!response.ok) {
      if (response.status === 401) {
        // 인증되지 않은 상태 처리
        window.location.href = '/login-page';
        return;
      }
      throw new Error('프로필 데이터 요청 실패');
    }
    
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('프로필 요청 오류:', error);
    throw error;
  }
}

// 로그아웃 함수
async function logout() {
  try {
    const response = await fetch('/logout', {
      method: 'POST',
      credentials: 'same-origin'
    });
    
    const data = await response.json();
    
    if (data.success) {
      // 로그아웃 후 처리 (예: 로그인 페이지로 리디렉션)
      window.location.href = '/login-page';
    }
  } catch (error) {
    console.error('로그아웃 오류:', error);
  }
}

쿠키 기반 인증의 장점

  1. 간편한 구현: 대부분의 웹 프레임워크와 라이브러리는 쿠키 기반 인증을 쉽게 구현할 수 있는 도구를 제공한다.
  2. 브라우저 호환성: 모든 브라우저가 쿠키를 지원하므로 별도의 클라이언트 측 구현이 거의 필요하지 않다.
  3. 자동 전송: 쿠키는 브라우저에 의해 자동으로 요청에 포함되어 전송되므로, 개발자가 각 요청마다 인증 정보를 포함시킬 필요가 없다.
  4. 상태 유지: 무상태 HTTP 프로토콜에서 사용자 상태를 효과적으로 유지할 수 있다.
  5. 서버 제어: 서버는 언제든지 세션을 무효화하거나 만료시킬 수 있어 보안 관리가 용이하다.

쿠키 기반 인증의 단점과 보안 고려사항

  1. CSRF(Cross-Site Request Forgery) 취약점:

    • 쿠키는 자동으로 요청에 포함되므로, 악의적인 웹사이트가 사용자의 브라우저를 통해 인증된 요청을 보낼 수 있다.
    • 이를 방지하기 위해 CSRF 토큰, SameSite 쿠키 설정 등의 추가 보호 조치가 필요하다.
  2. XSS(Cross-Site Scripting) 취약점:

    • 클라이언트 측 스크립트가 쿠키에 접근할 수 있으면 세션 하이재킹의 위험이 있다.
    • 이를 방지하기 위해 HttpOnly 플래그를 사용해야 한다.
  3. 쿠키 크기 제한:

    • 쿠키는 일반적으로 4KB 크기 제한이 있어, 대량의 데이터를 저장하기에는 적합하지 않다.
  4. 확장성 문제:

    • 서버 측에서 세션 상태를 유지해야 하므로, 대규모 분산 시스템에서는 세션 동기화 및 저장 문제가 발생할 수 있다.
  5. 모바일 앱 호환성:

    • 네이티브 모바일 앱에서는 쿠키 처리가 웹 브라우저와 다를 수 있어 추가 구현이 필요할 수 있다.

보안 강화 방법

CSRF 공격 방지

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// CSRF 토큰 구현 예시 (Express)
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });

// CSRF 보호가 필요한 라우트에 미들웨어 적용
app.get('/form', csrfProtection, (req, res) => {
  // CSRF 토큰을 폼에 포함
  res.render('form', { csrfToken: req.csrfToken() });
});

app.post('/process', csrfProtection, (req, res) => {
  // CSRF 토큰이 유효한 경우에만 처리
  // 처리 로직…
});

안전한 쿠키 설정

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 안전한 쿠키 설정 예시
app.use(session({
  // … 기타 옵션 …
  cookie: {
    httpOnly: true,           // JavaScript에서 쿠키 접근 방지
    secure: true,             // HTTPS에서만 쿠키 전송
    sameSite: 'strict',       // 동일 사이트 요청에서만 쿠키 전송
    domain: 'example.com',    // 특정 도메인에서만 유효
    path: '/',                // 모든 경로에서 유효
    maxAge: 3600000           // 1시간 후 만료
  }
}));
SameSite
속성 값동작 방식보안 수준사용 사례장점 및 단점
Strict동일한 사이트에서 시작된 요청에만 쿠키 전송매우 높음금융 서비스, 민감 데이터 처리 애플리케이션✅ 높은 보안 제공
❌ 크로스 사이트 기능 제한으로 사용자 경험 저하 가능
Lax최상위 네비게이션(GET 요청)에는 쿠키 전송, 백그라운드 요청에는 전송되지 않음중간로그인 폼 리디렉션, 일반적인 웹 애플리케이션✅ 보안과 유연성의 균형
❌ 일부 크로스 사이트 시나리오에서는 제한적
None모든 요청(동일/크로스 사이트)에 쿠키 전송낮음 (HTTPS 필수)광고 추적, SSO, 분석 도구✅ 제3자 컨텍스트 지원
❌ HTTPS 필요 및 보안 위험 증가

세션 하이재킹 방지

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 세션 하이재킹 방지 예시 (Express)
app.use(session({
  // … 기타 옵션 …
  rolling: true,              // 각 요청마다 세션 쿠키 갱신
  regenerate: true,           // 주기적으로 세션 ID 재생성
  saveUninitialized: false    // 초기화되지 않은 세션 저장 방지
}));

// 사용자 IP 또는 사용자 에이전트 변경 감지
app.use((req, res, next) => {
  const currentIp = req.ip;
  const currentUserAgent = req.headers['user-agent'];
  
  if (req.session.user) {
    // 첫 로그인 시 IP와 사용자 에이전트 저장
    if (!req.session.ip) {
      req.session.ip = currentIp;
      req.session.userAgent = currentUserAgent;
    }
    
    // 이전 세션 정보와 비교
    if (req.session.ip !== currentIp || req.session.userAgent !== currentUserAgent) {
      console.warn('잠재적 세션 하이재킹 시도 감지');
      return req.session.destroy(() => {
        res.redirect('/login');
      });
    }
  }
  
  next();
});

실제 적용 시나리오

전통적인 웹 애플리케이션

전통적인 서버 렌더링 웹 애플리케이션은 쿠키 기반 인증이 매우 적합하다. 사용자가 로그인하면 서버는 세션을 생성하고 세션 ID가 포함된 쿠키를 설정한다. 이후 모든 요청에서 서버는 이 쿠키를 통해 사용자를 식별하고 인증된 사용자에게 적절한 페이지를 렌더링한다.

하이브리드 애플리케이션

SPA(Single Page Application)와 API 서버가 동일한 도메인에 있는 경우, 쿠키 기반 인증은 여전히 좋은 선택이다. SPA는 API 요청 시 credentials: 'include' 옵션을 설정하여 쿠키를 함께 전송할 수 있다.

분산 시스템

여러 서버나 마이크로서비스 아키텍처에서는 Redis와 같은 중앙 집중식 세션 저장소를 사용하여 세션 정보를 공유할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const redis = require('redis');
const redisClient = redis.createClient();

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: '안전한_비밀_키',
  resave: false,
  saveUninitialized: false,
  cookie: { /* 보안 설정 */ }
}));

최신 트렌드와 모범 사례

하이브리드 접근법

많은 현대 웹 애플리케이션은 쿠키와 토큰의 장점을 결합한 하이브리드 접근법을 사용한다. 예를 들어, JWT를 쿠키에 저장하여 자동 전송의 이점을 누리면서도 토큰 기반 인증의 확장성을 확보할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// JWT를 HTTP-only 쿠키에 저장하는 예시
app.post('/login', (req, res) => {
  // 인증 로직…
  
  const token = jwt.sign({ userId: user.id }, 'secret_key', { expiresIn: '1h' });
  
  res.cookie('token', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 3600000 // 1시간
  });
  
  res.json({ success: true });
});

// 미들웨어에서 쿠키로부터 JWT 검증
app.use((req, res, next) => {
  const token = req.cookies.token;
  
  if (!token) {
    return res.status(401).json({ error: '인증이 필요합니다' });
  }
  
  try {
    const decoded = jwt.verify(token, 'secret_key');
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ error: '유효하지 않은 토큰입니다' });
  }
});

보안 최적화

최신 보안 모범 사례는 다음과 같은 접근법을 권장한다:

  1. SameSite=Lax 기본 설정: 최신 브라우저는 쿠키의 기본 SameSite 값을 ‘Lax’로 설정하여 CSRF 공격을 줄인다.
  2. 브라우저 지문(Fingerprinting): 세션과 함께 브라우저 특성을 저장하여 비정상적인 로그인 시도를 감지한다.
  3. 점진적인 세션 재검증: 민감한 작업(결제, 비밀번호 변경 등)에 대해 추가 인증을 요구한다.
  4. Content-Security-Policy(CSP): XSS 공격으로부터 보호하여 세션 쿠키 탈취 가능성을 줄인다.

용어 정리

용어설명

참고 및 출처