로그인 API가 200 OK를 반환했다는 사실만으로 로그인 유지가 검증되지는 않습니다. 브라우저가 인증 쿠키를 사용할 수 있었는지, 다음 요청에 보냈는지, CORS가 응답을 프론트 코드에 공개했는지, 상태 변경 요청이 CSRF 검사를 통과했는지, 만료 시 여러 요청이 갱신을 중복 실행하지 않는지를 각각 관찰해야 합니다.
2026년 9월 27일 서로 다른 동적 포트의 loopback 출처 두 개를 만들고 Chrome 153.0.8010.54와 Node.js v24.19.0으로 7개 시나리오를 실행했습니다. 실제 사용자 계정·비밀번호·운영 토큰은 쓰지 않았고 session-123, token-123, fresh-token이라는 가짜 값만 사용했습니다.
두 출처에서 브라우저 요청 7개를 실행했다
| 시나리오 | 브라우저 관찰 | 판정 |
|---|---|---|
정확한 Origin + credentials: include |
login 200 → session 200, 서버가 쿠키 확인 | 통과 |
Origin * + credentials |
TypeError |
CORS 응답 공유 거부 |
정확한 Origin, Allow-Credentials 없음 |
TypeError |
credentialed 응답 공유 거부 |
로그인 요청 credentials: omit |
login 200 → session 401 | Set-Cookie를 인증 상태로 사용하지 못함 |
| 쿠키 있음, CSRF header 없음 | 403, 서버는 cookie=true·csrf=false 확인 | 상태 변경 거부 |
| 쿠키와 CSRF header 있음 | preflight 1회 뒤 200 | 상태 변경 허용 |
| 보호 API 네 건이 동시에 401 | 각자 갱신 4회, Promise 공유 1회 | 갱신 직렬화 차이 확인 |

9개 기대 조건이 모두 통과했습니다. 정확한 Origin과 credentials 포함 조합에서는 HttpOnly 쿠키가 document.cookie에 나타나지 않았지만 후속 /session 요청에서 서버가 쿠키를 받아 200을 반환했습니다. 즉 “자바스크립트에서 쿠키가 안 보인다”와 “브라우저가 쿠키를 전송하지 않는다”는 같은 관찰이 아닙니다.
CORS 오류와 인증 실패를 같은 401로 묶지 않는다
WHATWG Fetch 표준에서 credential mode include는 요청 자격 증명뿐 아니라 응답의 자격 증명을 사용할지에도 영향을 줍니다. 이 모드에서 Access-Control-Allow-Origin: *는 허용 조합이 아니며, 응답을 공유하려면 직렬화한 정확한 Origin과 Access-Control-Allow-Credentials: true가 필요합니다.
// 프론트 요청
fetch('https://api.example.test/session', {
credentials: 'include',
});
// API 응답 예시
Access-Control-Allow-Origin: https://app.example.test
Access-Control-Allow-Credentials: true
Vary: Origin
로컬 실험에서도 와일드카드 Origin과 credentials 조합, Allow-Credentials 누락 조합은 모두 fetch Promise가 TypeError로 거부됐습니다. 이때 서버가 요청을 받았는지와 프론트 JavaScript가 응답을 읽을 수 있는지는 별개입니다. 따라서 Network 패널에서 요청 자체, preflight, 실제 응답 헤더, 콘솔의 CORS 오류를 함께 봅니다.
CORS는 사용자를 인증하는 장치도, CSRF 방어 전체도 아닙니다. 허용 Origin을 정확히 제한했더라도 서버는 세션·토큰 검증과 상태 변경 방어를 별도로 해야 합니다. 실제 운영에서 로그인 유지가 끊기는 증상 기록은 웹앱 로그인 유지 오류의 저장·전송·갱신 점검과 대조할 수 있습니다.
credentials: omit은 성공 응답과 로그인 상태를 갈라놓았다
로그인 응답에 Set-Cookie가 있어도 fetch의 credentials mode가 omit이면 응답으로 돌아온 credentials는 무시됩니다. 격리 실험에서 로그인 응답 자체는 200이었지만, 다음 session 요청은 쿠키를 받지 못해 401이었습니다.
따라서 다음 네 값을 분리해 기록합니다.
- 로그인 응답 status가 200인가?
- 응답에 올바른
Set-Cookie가 있었는가? - 브라우저 저장소에서 쿠키가 거부됐다면 이유가 무엇인가?
- 다음 보호 요청에서 서버가 해당 쿠키 또는 Authorization 값을 실제로 받았는가?
HttpOnly는 스크립트의 쿠키 읽기를 제한하지만 브라우저의 HTTP 전송을 막는 속성이 아닙니다. RFC 6265가 설명하는 것처럼 HttpOnly 쿠키는 스크립트 API 범위에서는 제외되지만 HTTP 요청에는 사용될 수 있습니다. 반대로 HttpOnly만 붙였다고 CSRF가 해결되지는 않습니다.
쿠키가 있어도 CSRF 토큰이 없으면 403으로 막았다
같은 로그인 쿠키를 가진 상태에서 POST /transfer를 두 번 보냈습니다. 첫 요청은 CSRF header 없이 보내 서버가 cookie=true, csrf=false를 기록하고 403을 반환했습니다. 두 번째 요청은 X-CSRF-Token: token-123을 넣었고 OPTIONS preflight 1회 뒤 200을 반환했습니다.
if (!hasSessionCookie || request.headers['x-csrf-token'] !== expected) {
return response.status(403).json({ accepted: false });
}
이 코드는 동작 차이를 보여 주는 최소 재현이지 그대로 복사할 완성 보안 구현이 아닙니다. 실제 서비스에서는 프레임워크의 검증된 CSRF 기능, 예측 불가능하고 사용자 세션에 결합된 토큰, Origin 검증, 토큰 누출 방지, 중요 작업 재인증을 위협 모델에 맞게 적용합니다. OWASP도 AJAX 요청에서 custom header로 CSRF 토큰을 전달하는 패턴을 설명하며 토큰을 URL이나 로그에 노출하지 않도록 권고합니다.
동시 401 네 건을 refresh 한 건으로 합쳤다
가짜 access token을 만료 상태로 두고 보호 API 네 건을 동시에 호출했습니다. 각 요청이 독립적으로 refresh를 실행한 구현은 보호 API 8회(초기 4회와 재시도 4회), refresh 4회를 만들었습니다. 진행 중인 Promise를 공유한 구현은 보호 API 수는 같았지만 refresh가 1회였습니다. 최종 네 요청은 두 구현 모두 200이었습니다.
let refreshPromise = null;
async function refreshOnce() {
if (!refreshPromise) {
refreshPromise = requestRefresh()
.finally(() => { refreshPromise = null; });
}
return refreshPromise;
}
이 결과는 한 탭의 한 JavaScript 실행 환경에서 Promise를 공유한 경우입니다. 여러 탭, service worker, 여러 서버 인스턴스에는 그대로 확장되지 않습니다. 서버는 refresh token 회전·폐기·재사용 탐지와 응답 유실 정책을 별도로 가져야 합니다. OAuth 2.0 Security BCP인 RFC 9700은 public client의 refresh token을 sender-constrained로 만들거나 회전해 재사용을 탐지하는 방식을 다룹니다.
중복 요청을 합치더라도 실패를 무한 재귀로 다시 호출해서는 안 됩니다. 429·Retry-After와 재시도 예산은 웹 API 429 백오프와 요청 제한 점검처럼 인증 갱신과 별도의 정책으로 둡니다. 동시 요청·중복 쓰기·재시도 흐름을 출시 전 확인하는 방식은 앱 출시 전 상태·동시성·오류 흐름 테스트로 이어집니다.
Network·Storage·서버 로그를 한 줄로 연결하는 표
| 관찰 위치 | 확인할 값 | 값이 없을 때 다음 분기 |
|---|---|---|
| Network 로그인 응답 | status, Set-Cookie, CORS headers, request ID | 서버 발급·proxy header·Origin 설정 |
| Application/Storage | cookie domain·path·expiry·SameSite·Secure와 차단 이유 | 브라우저 정책과 운영 HTTPS 배치 |
| 다음 보호 요청 | Cookie 또는 Authorization 전송 여부 | credentials 설정·도메인·path·만료 |
| preflight | 허용 Origin·method·header·credentials | 요청 header와 서버 허용 목록 대조 |
| 서버 인증 로그 | 원문 토큰이 아닌 request·session record ID와 실패 유형 | 없음·만료·폐기·재사용을 분리 |
| refresh 구간 | 동시 401 수, refresh 수, 재시도 수, 최종 상태 | single-flight와 실패 예산 확인 |
프론트와 API를 다른 출처에 둘지 같은 출처 뒤 reverse proxy로 묶을지는 초기 배포 구조에 영향을 줍니다. 웹앱 배포 범위를 결정할 때는 웹앱으로 시작할 때의 기능·배포 경계도 함께 정합니다.
이번 브라우저 재현의 범위 밖
- HTTP loopback의 서로 다른 포트 두 개를 사용했으며 운영 HTTPS와 실제 도메인을 배포하지 않았습니다.
SameSite=None,Secure, cross-site 배치, 서드파티 쿠키 차단, 시크릿 모드와 다른 브라우저는 테스트하지 않았습니다.- CSRF 검사는 고정된 가짜 header의 최소 재현이며 실제 세션 결합 토큰·Origin 정책 전체를 구현하지 않았습니다.
- refresh는 가짜 문자열만 반환했고 DB 저장, token rotation, 재사용 탐지, 여러 탭·기기·서버 인스턴스는 재현하지 않았습니다.
- CORS 통과는 인증·인가 성공이나 응답 내용의 안전성을 보장하지 않습니다.