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);
}
);
비교 분석
웹
vsCookie + Session
웹
vsLocalStorage 저장
트레이드오프
이상적인 사용 사례