FEInterview Prep

network

웹 인증과 보안: Session, JWT, OAuth 2.0, XSS, CSRF, CORS

보안은 시니어 개발자가 반드시 갖춰야 할 역량입니다. "JWT를 LocalStorage에 저장하면 안 되나요?", "CORS는 왜 에러가 나고 어떻게 해결하나요?", "XSS와 CSRF의 차이가 무엇인가요?" 같은 질문에 정확한 보안 원리를 설명할 수 있어야 합니다. 단순 사용법이 아닌 공격 원리와 방어 메커니즘을 이해해야 합니다.

시작 전
이해도
매우 낮음

학습 개요

탄생 배경

해결하려 했던 문제

HTTP는 무상태(Stateless) 프로토콜입니다. 각 요청이 독립적이므로 서버는 이전 요청을 기억하지 못합니다. 따라서 로그인 상태 유지를 위한 별도 인증 메커니즘이 필요했습니다. 초기 웹은 서버 세션으로 해결했지만, 마이크로서비스 아키텍처와 모바일/API 환경에서는 세션의 한계가 드러났고, JWT 같은 토큰 기반 인증이 등장했습니다.

역사적 맥락

1994년 쿠키 발명(Netscape) → 서버 세션 기반 인증 시대 → 2006년 OAuth 1.0 → 2012년 OAuth 2.0 표준화 → 2010년 JWT 개념 등장, 2015년 RFC 표준화 → 2019년 HttpOnly, SameSite 쿠키 광범위 지원 → 2020년대 Zero Trust 아키텍처 부상

이전에는 어떻게 했나

기본 인증(Basic Authentication): 매 요청마다 Base64 인코딩된 사용자명/비밀번호 전송 → 매우 취약. Digest Authentication: 비밀번호를 해시로 전송하지만 여전히 한계 있음. API Key: 서버 to 서버 통신에 주로 사용.

멘탈 모델

동작 원리

[Cookie + Session 인증 동작 원리] 1. 사용자가 로그인 정보(ID, PW) 전송 2. 서버: 검증 후 서버 메모리/DB에 Session 생성 (sessionId: 'abc123', userId: 1, expiry: ...) 3. 서버 → 클라이언트: Set-Cookie: sessionId=abc123; HttpOnly; Secure 4. 이후 요청: 브라우저가 자동으로 Cookie에 sessionId 포함 5. 서버: Cookie의 sessionId로 세션 저장소 조회 → 사용자 확인 특징: 서버가 상태를 유지 (Stateful). 로그아웃 시 서버에서 세션 삭제. 단점: 서버 수평 확장 시 세션 공유 문제 (Redis 등으로 해결 가능). [JWT 구조와 동작 원리] JWT = Base64URL(Header) + "." + Base64URL(Payload) + "." + Signature Header: { "alg": "HS256", "typ": "JWT" } Payload: { "sub": "1234567890", "name": "Kim", "iat": 1516239022, "exp": 1516242622 } Signature: HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret) 서명 검증: 서버가 secret key로 서명을 재계산하여 전송된 서명과 비교. 서버가 별도 저장소 조회 없이 토큰 자체를 검증 → Stateless. 대칭키(HS256): 서명과 검증에 같은 secret 사용. 단일 서버에 적합. 비대칭키(RS256): Private Key로 서명, Public Key로 검증. 마이크로서비스에서 검증 서버만 Public Key 보유. [JWT의 치명적 취약점] 1. 탈취 시 무효화 불가: JWT는 서버에 상태가 없으므로, 탈취된 토큰을 만료 전에 차단할 방법이 없음. 해결책: 짧은 Access Token 만료 시간(15분) + Refresh Token 전략. 2. Payload가 Base64URL 인코딩이므로 누구나 디코딩 가능. 민감 정보 포함 금지. 3. alg: none 공격: 알고리즘을 none으로 위조하는 공격. 라이브러리에서 이미 방어됨. [Access Token + Refresh Token 전략] Access Token: 짧은 만료(15분~1시간), 매 API 요청에 사용. Refresh Token: 긴 만료(7일~30일), Access Token 재발급에만 사용. Access Token 탈취 → 최대 15분만 유효 Refresh Token 탈취 → HttpOnly 쿠키에 저장하여 XSS로 탈취 불가 Refresh Token 만료 → 재로그인 필요 [XSS (Cross-Site Scripting) 공격 원리와 방어] 공격: 악의적인 JavaScript를 웹사이트에 삽입. 다른 사용자의 브라우저에서 실행. Stored XSS: 게시판에 악성 스크립트 저장 → 다른 사용자가 페이지 열 때 실행. Reflected XSS: URL 파라미터에 스크립트 삽입 → 서버가 그대로 반환. 공격 목적: Cookie/LocalStorage 탈취, 사용자 행동 조작. 공격자가 삽입하는 코드 예: document.cookie를 공격자 서버로 전송하는 스크립트. 방어: 1. Content Security Policy(CSP): 허용된 출처의 스크립트만 실행. 2. HttpOnly Cookie: JavaScript에서 document.cookie로 접근 불가. 3. 입력값 이스케이핑: <, >, &, ' 등을 HTML 엔티티로 변환. 4. 사용자 입력을 DOM에 삽입 시 textContent 사용 (innerHTML 지양). 5. DOMPurify 라이브러리로 HTML 새니타이징. [CSRF (Cross-Site Request Forgery) 공격 원리와 방어] 공격: 피해자가 로그인된 상태에서 악의적인 사이트가 피해자를 대신해 요청 전송. 브라우저는 쿠키를 자동으로 포함하므로, 서버는 합법적인 요청으로 인식. 악의적인 사이트가 피해자 브라우저로 하여금 bank.com에 송금 GET 요청을 자동 전송하게 할 수 있음. (img 태그의 src, form의 action 등을 통해) 방어: 1. SameSite 쿠키: 다른 사이트에서 쿠키가 전송되지 않도록 제한. SameSite=Strict: 동일 사이트 요청에만 쿠키 전송. SameSite=Lax: GET 요청에는 허용, POST 등에는 차단 (기본값). 2. CSRF Token: 서버가 발급한 토큰을 폼에 포함. 공격자는 이 토큰을 알 수 없음. 3. Double Submit Cookie: 쿠키와 헤더 모두에 CSRF 토큰을 보내는 방식. [CORS (Cross-Origin Resource Sharing) 동작 원리] Same-Origin Policy: 브라우저의 보안 정책. 다른 출처의 응답을 JavaScript에서 읽지 못하도록 제한. 출처(Origin) = Protocol + Domain + Port (셋 중 하나라도 다르면 다른 출처) CORS: 서버가 특정 다른 출처의 요청을 허용한다고 명시하는 메커니즘. Preflight 요청 (OPTIONS 메서드): 단순 요청(Simple Request) 이외의 경우, 실제 요청 전에 OPTIONS 요청을 먼저 보냄. 서버가 해당 요청을 허용하는지 확인. 단순 요청 조건: GET/POST/HEAD + 제한된 헤더 + application/x-www-form-urlencoded 등 Preflight 유발: PUT/DELETE, custom 헤더, application/json Content-Type 등 서버 응답 헤더: Access-Control-Allow-Origin: https://example.com Access-Control-Allow-Methods: GET, POST, PUT Access-Control-Allow-Headers: Authorization, Content-Type

핵심 구성 요소

HttpOnly Cookie

JavaScript에서 접근 불가 → XSS로부터 토큰 보호

SameSite Cookie

다른 사이트에서의 쿠키 전송 제한 → CSRF 방어

JWT Signature

서버의 secret key로 생성한 서명. 위변조 감지

Preflight Request

실제 요청 전 OPTIONS로 CORS 허용 여부 확인

Content Security Policy

XSS 방어: 허용된 출처의 스크립트만 실행하도록 제한

OAuth 2.0

제3자 앱에 자원 접근 권한을 위임하는 표준 프로토콜

흐름 설명


[OAuth 2.0 Authorization Code Flow]
1. 사용자: "구글로 로그인" 클릭
2. 클라이언트 → 구글 인증 서버: GET /oauth/authorize?client_id=...&redirect_uri=...&scope=email
3. 구글: 사용자에게 "이 앱이 이메일 정보에 접근하도록 허용하시겠습니까?" 확인
4. 사용자: 동의
5. 구글 → 클라이언트: redirect_uri?code=AUTHORIZATION_CODE (일회성 코드)
6. 클라이언트 서버 → 구글 토큰 서버: POST /oauth/token { code, client_id, client_secret }
7. 구글 → 클라이언트 서버: { access_token, refresh_token, id_token }
8. 클라이언트 서버: access_token으로 구글 API 호출하여 사용자 정보 획득
9. 클라이언트 서버: 자체 세션/JWT 발급 → 클라이언트에게 전달

[Refresh Token 갱신 흐름]
1. 클라이언트: API 요청에 Access Token 포함
2. 서버: Access Token 만료 → 401 응답
3. 클라이언트: Refresh Token으로 /auth/refresh 요청
4. 서버: Refresh Token 검증 → 새 Access Token 발급
5. 클라이언트: 새 Access Token으로 원래 요청 재시도
    

코드 예제


// HttpOnly + SameSite 쿠키로 Refresh Token 안전하게 저장
// 서버 (Express)
res.cookie('refreshToken', token, {
  httpOnly: true,          // JavaScript 접근 불가 → XSS 방어
  secure: true,            // HTTPS에서만 전송
  sameSite: 'strict',      // 다른 사이트에서 전송 불가 → CSRF 방어
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
});

// Access Token은 메모리(변수)에 저장 (XSS, CSRF 모두 안전)
// LocalStorage는 XSS에 취약, Cookie는 CSRF 가능 (SameSite 없이)
let accessToken: string | null = null;

// CSP 헤더 설정 (XSS 방어)
// Next.js next.config.js
const securityHeaders = [
  {
    key: 'Content-Security-Policy',
    value: [
      "default-src 'self'",
      "script-src 'self' https://cdn.example.com",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "connect-src 'self' https://api.example.com",
    ].join('; '),
  },
];

// CORS Preflight 요청 처리 (서버 측)
// 단순 요청(Simple Request)이 아닌 경우 브라우저가 자동으로 OPTIONS 전송
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',  // Preflight 유발
    'Authorization': 'Bearer token',      // Preflight 유발
  },
  body: JSON.stringify({ data: 'value' }),
});

// XSS 방어: 사용자 입력 안전하게 DOM에 삽입
// 나쁜 예: 사용자 입력을 그대로 HTML로 파싱 (XSS 위험)
// element.innerHTML = userInput; // 절대 사용 금지

// 좋은 예: 텍스트로만 처리
element.textContent = userInput; // HTML 파싱 없이 텍스트로 삽입

// HTML이 반드시 필요한 경우 DOMPurify로 새니타이징
// import DOMPurify from 'dompurify';
// element.innerHTML = DOMPurify.sanitize(userInput);

// Axios 인터셉터로 토큰 자동 갱신 구현
axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      const newAccessToken = await refreshAccessToken();
      accessToken = newAccessToken;
      originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
      return axios(originalRequest);
    }
    return Promise.reject(error);
  }
);
    

비교 분석

vs

Cookie + Session

비교 관점
이 방식
Cookie + Session
상태 유지
JWT: Stateless (서버에 상태 없음)
Session: Stateful (서버에 세션 저장)
수평 확장
JWT: 쉬움 (서버 간 공유 불필요)
Session: Redis 등 공유 저장소 필요
로그아웃/강제 만료
JWT: 만료 전 토큰 무효화 어려움
Session: 서버에서 즉시 세션 삭제 가능
토큰 크기
JWT: 크다 (Payload 포함, 매 요청 포함)
Session ID: 매우 작다 (단순 식별자)
마이크로서비스
JWT: 각 서비스가 Public Key로 독립 검증
Session: 모든 서비스가 세션 저장소 공유 필요

vs

LocalStorage 저장

비교 관점
이 방식
LocalStorage 저장
XSS 취약성
HttpOnly Cookie: JavaScript 접근 불가 → XSS로부터 안전
LocalStorage: JavaScript로 접근 가능 → XSS에 취약
CSRF 취약성
Cookie: 자동 전송되어 CSRF 위험 (SameSite로 방어)
LocalStorage: 자동 전송 없음 → CSRF 불가 (수동 헤더 추가)
용량
Cookie: 4KB 제한
LocalStorage: 5~10MB
서버 접근
Cookie: 서버 Set-Cookie로 설정 가능
LocalStorage: 클라이언트 JavaScript만 접근 가능

트레이드오프

이상적인 사용 사례

면접 질문