쿠키·세션·토큰과 CORS 기초: 로그인 정보와 출처 구분하기

2026.09.05·수정 2026.09.12·약 24분·작성: 해비·블로그 소개

이 글에서 정리하는 내용

쿠키는 저장·전송 수단, 세션은 로그인 상태의 연결, 토큰은 자격 증명을 나타내는 값이라는 차이를 설명합니다. 두 로컬 서버에서 HttpOnly 쿠키와 동일 출처·CORS·preflight를 비교합니다.

목표·선수 지식: HTTP 상태·헤더·본문과 JavaScript fetch/await를 먼저 익히세요. 같은 호스트의 두 포트가 다른 출처인 이유, 쿠키 첨부와 CORS 응답 공개가 별도 조건인 이유를 관찰하는 완성 예제(TPL-02)입니다.

실습 경로: 현재 ZIP전체 코드. 먼저 설명에서 지정한 파일만 읽고, 설정·테스트 파일은 필요한 때 확인하세요.

쿠키·세션·토큰을 같은 종류로 비교하지 마세요

서버는 HTTP 요청 하나를 받을 때 그 요청에 포함된 정보로 사용자를 판단합니다. 로그인 화면을 이미 봤거나 프론트엔드 변수에 사용자 이름이 있다는 사실만으로 다음 API 요청의 권한이 보장되지는 않습니다. 인증은 누구인지 확인하는 과정이고, 인가는 그 사용자가 어떤 데이터를 읽고 바꿀 수 있는지 확인하는 과정입니다.

인증과 접근 허용의 경계를 구분합니다.
인증과 접근 허용의 경계를 구분합니다.
개념 담당하는 것 예시
쿠키 브라우저의 작은 값 저장과 조건부 자동 전송 세션 식별자를 Cookie 헤더로 전송
서버 세션 여러 요청을 서버가 보관한 로그인 상태에 연결 불투명한 session ID → 사용자·만료 정보
토큰 서버가 검증해야 하는 자격 증명 값 Authorization의 Bearer access token

일반적인 서버 세션은 로그인 성공 시 새 예측 불가능한 식별자를 발급하고 서버 저장소에 사용자와 만료 정보를 연결합니다. 브라우저 쿠키에는 그 식별자를 담고, 다음 요청에서 서버가 조회합니다. 로그인·권한 변경 시 식별자 갱신, 만료·로그아웃 시 서버 쪽 무효화가 필요합니다. OWASP: Session Management

Bearer 토큰은 가진 사람이 사용할 수 있는 자격 증명입니다. 서버는 토큰을 검증하고 허용 범위를 확인해야 합니다. 토큰이 항상 JWT인 것은 아니며 JWT를 쓴다고 내용이 자동 암호화되는 것도 아닙니다. RFC 7519: JSON Web Token 토큰을 쿠키로 운반하는 설계도 가능하므로 “쿠키 대 토큰”만으로 인증 방식을 결정할 수 없습니다. RFC 6750: Bearer Token Usage

Authorization 헤더는 브라우저 저장소에 있는 임의의 문자열을 자동 검색해 붙이지 않습니다. 애플리케이션 코드나 인증 라이브러리가 정해진 인증 흐름에서 값을 넣습니다. 브라우저가 조건에 맞춰 자동 첨부하는 쿠키와 이 차이를 구분하세요. MDN: Authorization header

서버의 Set-Cookie 응답 헤더는 브라우저에 쿠키 저장을 지시하고, 이후 조건에 맞는 요청에서는 Cookie 헤더로 값이 돌아옵니다. HttpOnly는 페이지 JavaScript가 값을 읽는 것을 제한하지만 브라우저의 요청 첨부는 유지합니다. Secure는 보안 전송을 요구합니다. Domain을 생략하면 설정한 호스트 범위로 제한되고 Path는 전송할 경로 범위를 정합니다. MDN: Using HTTP cookies

동일 출처는 scheme·host·port가 모두 같은지로 판단합니다. 경로와 쿼리가 달라져도 출처는 같습니다. SameSite의 사이트 경계는 출처와 다릅니다. 포트만 다른 두 URL은 다른 출처이면서 같은 사이트일 수 있습니다. MDN: Same-origin policy

기준: http://127.0.0.1:3000 출처 비교 이유
http://127.0.0.1:3000/profile 같음 경로만 다름
http://127.0.0.1:4000 다름 포트 다름
http://localhost:3000 다름 호스트 문자열 다름
https://127.0.0.1:3000 다름 scheme 다름

쿠키 자체는 포트별로 격리되지 않습니다. 실습에서 두 포트는 출처가 다르지만 같은 호스트 쿠키의 범위에 들어갈 수 있습니다. 따라서 API 요청은 credentials: omit으로 쿠키를 보내지 않게 하고 CORS만 관찰합니다. SameSite=Lax는 cross-site 쿠키 전송을 제한하며 일부 최상위 안전 메서드 탐색에는 허용합니다. None은 Secure가 필요하고 브라우저의 제3자 쿠키 정책도 적용됩니다. 쿠키 속성 하나가 로그인이나 CSRF 방어 전체를 대신하지는 않습니다.

CORS는 다른 출처 응답을 읽는 허용 규칙입니다

다른 출처의 데이터를 JavaScript가 읽으려면 서버가 CORS 응답 헤더로 허용 범위를 알려야 합니다. 이때 “요청이 서버에 도착했다”와 “페이지 코드가 응답을 읽었다”는 별개입니다. 아래 /blocked 실습은 서버가 200 JSON을 보내지만 브라우저 fetch는 응답을 공개하지 않는 경우입니다.

브라우저가 요청에 쿠키를 포함하는 조건을 확인합니다.
브라우저가 요청에 쿠키를 포함하는 조건을 확인합니다.

커스텀 헤더나 application/json 본문 같은 조건에서는 브라우저가 OPTIONS 사전 요청(preflight)을 보낼 수 있습니다. 서버의 허용 응답을 확인한 뒤 본 요청을 보냅니다. 모든 cross-origin 요청에 OPTIONS가 생기는 것은 아닙니다. MDN: CORS

CORS 헤더는 서버 응답에 설정합니다. 프론트엔드 요청 헤더에 Access-Control-Allow-Origin을 추가하거나 mode: no-cors로 바꿔 JSON을 읽으려는 방식은 해결책이 아닙니다. no-cors의 불투명 응답은 JavaScript에서 본문을 읽을 수 없습니다. MDN: Using Fetch

쿠키를 동반하는 cross-origin fetch를 설계한다면 credentials: include, 구체적인 Access-Control-Allow-Origin, Access-Control-Allow-Credentials: true와 쿠키 전송 조건을 함께 확인해야 합니다. 별표 origin은 이 응답 공개 조건을 충족하지 않습니다. 아래 예제는 로그인 API가 아니므로 include를 쓰지 않습니다. CORS는 사용자 인증·인가나 CSRF 검증을 대체하지 않습니다.

두 포트의 서버와 무해한 쿠키 준비하기

파일 구성: server.mjs는 두 서버와 /client.js 정적 경로를 제공하고, index.html은 화면, client.js는 초기 설정과 버튼 동작을 맡습니다. 없는 UI 경로는 404로 반환하므로 스크립트 주소 오타가 HTML 응답으로 숨겨지지 않습니다. 아래 뷰어의 세 파일을 같은 폴더에 저장하세요. server.mjs 전체 코드

Node.js가 설치된 빈 cors-lab 폴더에 뷰어의 세 파일을 저장합니다. HTTP 기초 실습의 서버와 별개 파일입니다. server.mjs는 UI와 API를 각각 다른 빈 포트에 띄우고, UI의 정확한 출처만 /allowed 응답에 허용합니다. Node.js 24.19.0에서 서버 응답을 확인했습니다. server.mjs 전체 코드

쿠키 lesson=seen은 페이지를 열었다는 연습용 표식이며 로그인이나 권한을 부여하지 않습니다. 그래서 이 로컬 HTTP 예제에는 비밀 데이터가 없고 Secure를 넣지 않았습니다. 운영용 인증 서버로 복사하지 마세요. HTTPS 인증 쿠키와 서버 검증은 사용 중인 인증 솔루션 지침에 맞게 구성해야 합니다.

현재 실습 ZIP

현재 본문과 같은 실습 파일 내려받기

server.mjs

import http from "node:http";
import { readFile } from "node:fs/promises";

const page = await readFile(new URL("./index.html", import.meta.url));
const client = await readFile(new URL("./client.js", import.meta.url));
let uiOrigin;
const api = http.createServer((request, response) => {
  const url = new URL(request.url, "http://127.0.0.1");
  console.log("API", request.method, url.pathname, request.headers.origin ?? "no-origin");
  response.setHeader("Cache-Control", "no-store");
  if (url.pathname === "/allowed" && request.headers.origin === uiOrigin) {
    response.setHeader("Access-Control-Allow-Origin", uiOrigin);
    response.setHeader("Vary", "Origin");
  }
  if (request.method === "OPTIONS" && url.pathname === "/allowed") {
    response.setHeader("Access-Control-Allow-Methods", "GET");
    response.setHeader("Access-Control-Allow-Headers", "X-Lesson");
    response.writeHead(204);
    response.end();
    return;
  }
  response.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
  response.end(JSON.stringify({ message: "API response", path: url.pathname }));
});
api.listen(0, "127.0.0.1", () => {
  const apiOrigin = `http://127.0.0.1:${api.address().port}`;
  const ui = http.createServer((request, response) => {
    response.setHeader("Cache-Control", "no-store");
    const url = new URL(request.url, "http://127.0.0.1");
    if (request.method !== "GET") {
      response.writeHead(405, { "Allow": "GET", "Content-Type": "text/plain; charset=utf-8" });
      response.end("GET only");
      return;
    }
    if (url.pathname === "/client.js") {
      response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
      response.end(client);
      return;
    }
    if (url.pathname === "/config") {
      response.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
      response.end(JSON.stringify({ apiOrigin }));
      return;
    }
    if (url.pathname === "/cookie-info") {
      const hasCookie = (request.headers.cookie ?? "").split(";")
        .some((part) => part.trim() === "lesson=seen");
      response.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
      response.end(JSON.stringify({ hasCookie }));
      return;
    }
    if (url.pathname !== "/") {
      response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
      response.end("Not found");
      return;
    }
    response.writeHead(200, {
      "Content-Type": "text/html; charset=utf-8",
      "Set-Cookie": "lesson=seen; Path=/; HttpOnly; SameSite=Lax"
    });
    response.end(page);
  });
  ui.listen(0, "127.0.0.1", () => {
    uiOrigin = `http://127.0.0.1:${ui.address().port}`;
    console.log(`UI ${uiOrigin}`);
    console.log(`API ${apiOrigin}`);
  });
});

index.html

<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>쿠키와 CORS 관찰</title>
  <style>
    body { max-width: 46rem; margin: 2rem auto; padding: 0 1rem; font-family: sans-serif; }
    button { padding: .75rem 1rem; }
    pre { white-space: pre-wrap; overflow-wrap: anywhere; }
  </style>
</head>
<body>
  <h1>쿠키와 CORS 관찰</h1>
  <button id="run" type="button" disabled>요청 비교</button>
  <pre id="result" aria-live="polite">초기 설정을 불러오는 중…</pre>
  <script type="module" src="/client.js"></script>
</body>
</html>

client.js

const result = document.querySelector("#result");
const button = document.querySelector("#run");
let apiOrigin;

async function inspect(label, url, options) {
  try {
    const response = await fetch(url, options);
    const data = await response.json();
    result.textContent += `${label}: ${response.status} ${JSON.stringify(data)}\n`;
  } catch (error) {
    result.textContent += `${label}: ${error.name} (Network에서 원인 확인)\n`;
  }
}

button.addEventListener("click", async () => {
  button.disabled = true;
  result.textContent = "";
  try {
    await inspect("same-origin cookie", "/cookie-info");
    await inspect("cross-origin blocked", `${apiOrigin}/blocked`, { credentials: "omit" });
    await inspect("cross-origin allowed", `${apiOrigin}/allowed`, { credentials: "omit" });
    await inspect("preflight", `${apiOrigin}/allowed`, {
      credentials: "omit",
      headers: { "X-Lesson": "cors" }
    });
  } finally {
    button.disabled = false;
  }
});

async function initialize() {
  try {
    const response = await fetch("/config");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const config = await response.json();
    if (typeof config.apiOrigin !== "string") throw new Error("apiOrigin 누락");
    const url = new URL(config.apiOrigin);
    if (url.protocol !== "http:" || url.hostname !== "127.0.0.1") {
      throw new Error("실습 API 주소가 아님");
    }
    apiOrigin = url.origin;
    result.textContent = "준비 완료: 요청 비교를 누르세요.";
    button.disabled = false;
  } catch (error) {
    result.textContent = `초기 설정 실패: ${error.message}. 서버와 /config 응답을 확인하고 새로 고치세요.`;
  }
}
initialize();

API는 Origin이 정확히 UI 주소일 때만 허용 헤더를 붙입니다. 외부에서 임의의 Origin을 보내도 그대로 반사하지 않습니다. OPTIONS의 허용 헤더 목록에는 이 실습이 쓸 X-Lesson만 넣었습니다. UI 서버의 /cookie-info는 쿠키 원문 대신 표식 존재 여부만 반환합니다.

버튼으로 네 요청을 비교하기

초기 실패도 관찰하기: client.jsinitialize()는 config의 HTTP 오류·JSON 해석 실패·연결 실패를 화면에 표시하며 버튼을 비활성 상태로 유지합니다. 개발자 도구의 요청 차단 기능에서 */config를 차단하고 새로 고쳐 “초기 설정 실패”를 확인한 뒤 차단을 해제하고 다시 새로 고치세요. 이 브라우저 재현 절차는 독자 확인 단계이며 아래 자동검증과 별개입니다. client.js 전체 코드

index.html은 버튼과 결과 영역을 제공하고, client.js는 같은 출처 쿠키 확인, CORS 헤더 없는 응답, 허용한 응답, 커스텀 헤더의 preflight 요청을 순서대로 실행합니다. 결과 표시는 client.js에서 textContent를 사용합니다. fetch와 await가 낯설면 JavaScript fetch 오류 처리를 먼저 읽으세요. index.html 전체 코드

터미널에서 아래 명령을 실행하고 출력된 UI 주소를 브라우저로 엽니다. API 주소를 직접 여는 것이 아니라 UI에서 API로 요청해야 출처 경계를 관찰할 수 있습니다. 실행을 끝낼 때는 Ctrl+C로 두 서버를 함께 종료합니다.

node server.mjs

Network 패널의 기록을 켠 다음 “요청 비교”를 누릅니다. 첫 요청은 현재 UI와 같은 출처이므로 기본 credentials 동작에 따라 표식 쿠키가 첨부됩니다. 두 번째 요청은 API 서버 로그에 남아도 CORS 허용 헤더가 없어 페이지에는 TypeError로 표시될 것으로 예상됩니다.

화면 라벨 예상 관찰 Network 확인
same-origin cookie 200, hasCookie: true /cookie-info의 Cookie 요청 헤더
cross-origin blocked TypeError /blocked는 서버에 도달해도 응답 읽기 제한
cross-origin allowed 200 JSON 정확한 Access-Control-Allow-Origin
preflight 200 JSON OPTIONS 204 이후 GET 200

마지막 요청은 X-Lesson 때문에 OPTIONS가 먼저 생깁니다. 사전 요청 캐시 상태에 따라 반복 클릭에서는 기록이 다르게 보일 수 있으니 처음 실행한 결과를 확인합니다. Application 또는 Storage의 쿠키 목록에서는 lesson을 볼 수 있지만 Console의 document.cookie에는 이 HttpOnly 쿠키가 나타나지 않아야 합니다. HttpOnly가 스크립트 실행 자체를 차단한다는 뜻은 아닙니다.

로그인 오류를 관찰할 순서

수정 위치를 연결해 보기: 응답 형식이 HTML이면 server.mjs의 정적 라우트를, 초기 설정 실패면 client.js의 initialize와 /config를 확인합니다. API가 200인데 브라우저가 TypeError를 표시하면 Origin과 허용 응답 헤더를 비교합니다. TypeError만으로 CORS를 확정하지 말고 서버 종료·연결 실패도 Network에서 구분하세요. server.mjs 전체 코드

판별: blocked가 서버에 도착했는데 실패로 표시되는 이유는?

전송과 응답 공개는 별개입니다. 서버는 200을 보낼 수 있지만 CORS 허용 헤더가 없으면 브라우저가 페이지 코드에 응답을 공개하지 않습니다. HttpOnly는 쿠키 값 읽기를 제한할 뿐 요청 첨부를 막지 않으며 쿠키는 포트로 격리되지 않습니다.

이 예제에서 검증한 것은 HTTP 서버의 쿠키·CORS 헤더 동작입니다. 실제 브라우저의 쿠키 저장·응답 읽기 차단과 버튼 화면은 위 절차로 별도 확인해야 합니다. Node fetch나 curl은 브라우저의 동일 출처 정책을 그대로 강제하지 않으므로 서버 요청 성공만으로 CORS 해결을 선언할 수 없습니다. Undici: CORS behavior in Node.js

질문 살펴볼 증거
서버가 자격 증명을 발급했나? 로그인 응답과 Set-Cookie 또는 정해진 토큰 응답
브라우저가 저장·전송했나? 쿠키 저장 목록과 다음 요청 Cookie/Authorization
서버가 검증했나? 유효성·만료·서버 세션과 권한 검사 결과
다른 출처 응답을 읽을 수 있나? Origin, preflight, CORS 응답 헤더
세션 종료가 반영됐나? 서버 무효화·만료와 후속 요청 거절

로그아웃 버튼으로 화면 이름만 지우는 것은 세션 종료가 아닙니다. 서버 세션 설계에서는 서버 저장소에서 세션을 무효화하고 쿠키도 만료시켜야 합니다. 토큰 설계의 로그아웃·취소 방식은 만료·갱신·폐기 전략에 따라 달라집니다. 프론트엔드가 숨긴 버튼이나 사용자 ID 값만으로 서버 권한을 판단하지 않습니다.

플랫폼 적용은 Supabase SSR 인증에서 서버 요청별 인증 문맥과 쿠키 연결로 이어서 확인할 수 있습니다. 이 기초를 익힌 뒤 CSRF·XSS 공격 구분을 읽으면 자동 첨부 인증 정보와 코드 실행 위험이 왜 서로 다른 문제인지 연결됩니다.

실습 파일 안내

ZIP의 complete/ 폴더에 뷰어와 같은 전체 실행 파일이 들어 있습니다. 압축을 풀고 이 폴더에서 node server.mjs를 실행하세요. 외부 패키지는 필요하지 않습니다. server.mjs 전체 코드

공식 자료 확인·실행 검증: 2026년 9월 12일. Node v24.19.0: 정적 JS 바이트/MIME, 없는 경로 404, Set-Cookie 및 수동 Cookie echo, blocked/allowed/잘못된 Origin, OPTIONS 204 헤더 확인. jsdom 모의 fetch로 초기 성공/HTTP/연결/JSON 실패 UI 확인. 브라우저 CORS와 쿠키 정책은 미실행. 공식 API·정책 근거

이 글이 도움이 되었나요?

조회 중

CS 기초 학습 순서

필수 3개 · 전체 3개

읽음 기록 관리

전체 과정 목차 (3개)
  1. 필수 학습 · 브라우저와 HTTP 기초: HTML 요청부터 응답 헤더 읽기까지
  2. 필수 학습 · 쿠키·세션·토큰과 CORS 기초: 로그인 정보와 출처 구분하기 현재 글
  3. 필수 학습 · CSRF XSS 차이: 프론트엔드 보안 공격 구분법

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기