TechFeedTechFeed
Frontend

쿠키 SameSite, CSRF, 사파리, 서드파티 | 로그인이 한 브라우저에서만 풀리는 이유는?

사파리에서만 로그인이 풀리면 SameSite와 도메인부터 본다. Lax, Strict, None, HttpOnly, Secure, 파티션 쿠키, CSRF 토큰, 오리진 검사, 카카오 콜백, 프론트와 API 분리를 프론트엔드, 보안, API, Next.js, 개발자, 브라우저 기준으로 같은 표에 둔다. CSP 헤더 글과 서버 액션 글과 각을 섞지 않는다.

by

사파리에서만 로그인이 풀리면 토큰 만료보다 쿠키 칸을 먼저 봅니다. SameSite를 안 적으면 최신 브라우저는 Lax로 봅니다. Lax는 주소창 이동 GET에는 붙고, 다른 사이트에서 온 POST와 페치에는 빠집니다. 프론트가 vercel.app이고 API가 회사 도메인이면 두 곳은 다른 사이트입니다. 세션을 None으로 열고 Secure를 빼면 크롬이 쿠키를 버립니다. 저는 카카오 콜백을 붙이기 전에 도메인, 스킴, SameSite, HttpOnly를 같은 표에 올립니다.


헤더 전체는 CSP 체크리스트를, 폼 제출은 서버 액션 글을 보면 자리가 겹치지 않습니다. 숫자는 2026년 8월 MDN과 넥스트, 오와스프 기준입니다.


사파리에서만 풀리는 로그인은 토큰이 아니다

크롬은 되고 사파리만 빈 세션이면, 만료 시각보다 Set-Cookie 칸이 다릅니다. 사파리는 지능형 추적 방지로 서드파티 쿠키를 거의 안 붙입니다. 크롬은 2026년 8월에도 서드파티 쿠키를 기본으로 허용하고, 설정에서 사용자가 막을 수 있습니다.


제가 12사이트 넥스트를 버셀에 올리며 반복한 밤은 이렇습니다. 로컬 크롬에서 로그인이 되고, 아이폰 사파리에서 새로고침하면 다시 로그인 화면입니다. 액세스 토큰은 로컬스토리지에 남아 있고, 리프레시만 HttpOnly 쿠키에 있습니다. 사파리는 그 쿠키를 크로스 사이트 요청에 안 실어 보냅니다. 네트워크 탭에 401이 쌓이고, 앱은 토큰이 썩었다고 착각합니다.


같은 증상은 카카오 팝업, 네이버 콜백, 결제 창 복귀에서도 납니다. 팝업이 닫히고 부모 창이 인터페이스를 치면, 쿠키가 안 따라옵니다. 배포 체크의 보안 헤더 칸은 앱 라우터 체크리스트에 있고, 여기는 쿠키가 안 붙는 자리만 봅니다. 아이폰에서만 재현되면 맥 크롬 통과로 끝내지 않습니다. 실기에서 콜백이 끝난 직후 애플리케이션 탭을 열어, 세션 줄이 생겼는지와 만료, 보호 칸이 비었는지를 같이 봅니다.


한 줄 가드 | 사파리만 풀린다고 토큰 TTL을 늘리지 않습니다. 개발자 도구 애플리케이션 탭에서 SameSite, Secure, HttpOnly, 파티션 키를 먼저 봅니다.


Lax와 Strict, None을 같은 표에 올리면

세션 쿠키 기본은 Lax와 HttpOnly, 운영 HTTPS에서는 Secure입니다. Strict는 외부 링크를 눌러 들어온 첫 GET에도 쿠키가 빠집니다. None은 크로스 사이트에도 붙지만 Secure가 없으면 브라우저가 버립니다.


MDN 기준으로 SameSite를 생략하면 최신 브라우저는 Lax로 취급합니다. 넥스트 cookies().set의 기본은 path만 슬래시입니다. sameSite와 httpOnly는 직접 적어야 합니다. 적지 않으면 라이브러리마다 기본이 갈립니다.


언제 붙나맞는 자리깨지는 자리
Strict같은 사이트에서 시작한 요청만관리자 세션, 비밀번호 변경메일 링크, 외부 광고 유입 첫 화면
Lax같은 사이트 + 주소창 이동 GET일반 로그인 세션다른 사이트에서 온 POST, iframe, 페치
None + Secure크로스 사이트에도 붙음다른 등록 도메인 API, 임베드Secure 없음, HTTP, 사파리 서드파티 차단
HttpOnly자바스크립트가 못 읽음리프레시, 세션 아이디프론트가 document.cookie로 읽는 설계
Partitioned임베드한 사이트별로 칸이 갈림위젯, CHIPS사이트 간 로그인 공유

웹닷데브는 민감 정보는 수명을 짧게 두라고 적습니다. 세션을 30일로 늘려 사파리 증상을 가리지 않습니다. 번들 크기와 LCP는 성능 예산 글에 두고, 여기는 쿠키 속성만 봅니다.


노트북 브라우저에서 로그인 화면과 개발자 도구를 열어 둔 프론트엔드 작업 장면
사파리만 풀리면 네트워크보다 애플리케이션 탭의 쿠키 칸을 먼저 연다

프론트와 API 도메인이 갈리면 세션이 안 붙는다

같은 사이트는 등록 도메인과 스킴이 같을 때입니다. www와 api가 둘 다 example.com의 HTTPS면 Lax로도 쿠키가 오갑니다. app.vercel.app과 api.example.com은 등록 도메인이 달라 다른 사이트입니다.


국내 팀이 자주 쓰는 구성이 바로 그 갈림입니다. 프론트는 버셀 기본 도메인, API는 가비아나 라우트53에 붙인 회사 도메인. 크롬은 한때 서드파티를 더 넓게 허용해서 넘어가고, 사파리는 첫날부터 막습니다. 고치는 순서는 None을 먼저 켜는 일이 아닙니다. API를 api.example.com으로 두고 프론트를 www.example.com으로 붙이면, Lax로 돌아옵니다.


도메인을 당장 못 합치면 넥스트 라우트 핸들러를 같은 출처 중간 서버로 둡니다. 브라우저는 공개 주소만 보고, 서버가 내부 인터페이스를 칩니다. 쿠키는 우리 사이트 것으로 남습니다. 시크릿을 프론트 번들에 넣는 자리는 환경변수 체크리스트와 겹칩니다. 여기는 출처가 갈리는 자리입니다. 서브도메인만 나누는 구성은 쿠키 도메인 칸을 넓히지 않아도 됩니다. 스테이징과 운영을 한 쿠키 이름으로 묶는 일만 피합니다.


넥스트 라우트에서 세션 쿠키를 Lax로 심는다
'use server' import { cookies } from 'next/headers' export async function setSessionCookie(token) { const store = await cookies() store.set({ name: 'session', value: token, httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 7, }) }

카카오 콜백을 붙이기 전에 채울 칸

카카오와 네이버는 리다이렉트가 같은 등록 도메인으로 돌아오면 Lax로 충분합니다. 팝업이 닫힌 뒤 부모가 다른 도메인 API를 치면 쿠키가 빠집니다. 콜백 URL을 프론트와 같은 사이트로 두고, 세션은 그 응답에서 심습니다.


카카오 개발자 문서의 Redirect URI는 콘솔에 등록한 값과 스킴까지 같아야 합니다. http 로컬과 https 운영을 한 칸에 섞으면 운영에서만 쿠키가 거절됩니다. 팝업을 고집하면 postMessage로 코드를 넘기고, 쿠키는 부모 출처의 라우트에서 심습니다. 팝업 도메인에 Set-Cookie를 남기면 사파리가 버립니다.


크로스 사이트 페치를 어쩔 수 없이 쓰면 credentials를 include로 두고, 서버는 Allow-Origin에 별표를 쓰지 않습니다. 별표와 Allow-Credentials true는 같이 못 갑니다. 모킹으로 핸들러만 검증하는 밤은 MSW 글을 보면 됩니다. 여기는 실제 Set-Cookie가 브라우저에 남는 자리입니다.


자격 증명을 실어 보낼 때 오리진을 고정한다
// 브라우저 await fetch('https://api.example.com/me', { credentials: 'include', }) // API. 별표와 Credentials는 같이 쓰지 않는다 const allow = new Set([ 'https://www.example.com', 'http://localhost:3000', ]) app.use((req, res, next) => { const origin = req.headers.origin if (origin && allow.has(origin)) { res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Access-Control-Allow-Credentials', 'true') res.setHeader('Vary', 'Origin') } next() })
스마트폰과 노트북에서 같은 웹 서비스 로그인 화면을 나란히 열어 둔 장면
카카오 콜백은 팝업 도메인이 아니라 프론트와 같은 사이트로 되돌린다

로컬호스트에서만 되고 배포에서 깨지는 자리

localhost와 127.0.0.1은 다른 사이트입니다. 프론트를 localhost:3000, API를 127.0.0.1:4000에 두면 Lax가 빠집니다. http와 https도 스킴이 달라 다른 사이트입니다. Secure true를 로컬 HTTP에 켜면 쿠키가 안 붙습니다.


제가 버셀 프리뷰마다 반복하는 실수는 이렇습니다. 프리뷰 URL이 매일 바뀌는데 API CORS 허용 목록은 운영 도메인만 있습니다. 크롬은 프리플라이트에서 막히고, 사파리는 쿠키만 조용히 뺍니다. 프리뷰는 같은 등록 도메인의 서브도메인으로 붙이거나, 허용 목록에 프리뷰 패턴을 넣습니다.


도메인 속성에 .example.com을 넓히면 하위 호스트가 같은 쿠키를 봅니다. 스테이징이 운영 세션을 훔칠 수 있습니다. 스테이징은 별도 쿠키 이름을 씁니다. 응답 헤더는 curl로 먼저 봅니다. 콘솔만 보면 Set-Cookie가 잘린 줄 모릅니다.


로그인 응답의 Set-Cookie 칸을 그대로 본다
curl -sI -X POST 'https://www.example.com/api/login' \ -H 'Content-Type: application/json' \ -H 'Origin: https://www.example.com' \ --data '{"email":"dev@example.com"}' # 볼 줄 # set-cookie: session=...; Path=/; HttpOnly; Secure; SameSite=Lax

CSRF 토큰을 빼도 되는 밤은 거의 없다

Lax는 다른 사이트의 POST 위조를 많이 막습니다. 그래도 GET으로 상태를 바꾸거나, SameSite를 None으로 열거나, 하위 도메인을 넓히면 위조가 다시 열립니다. 오와스프는 동기화 토큰이나 오리진 검사를 같이 두라고 적습니다.


넥스트 서버 액션은 같은 출처 폼이 기본이라 SameSite와 맞물립니다. 별도 API에 JSON POST를 열면 Origin 헤더를 허용 목록과 대조합니다. Origin이 비고 Sec-Fetch-Site가 cross-site면 거절합니다. 쿠키만 믿고 상태 변경 GET을 남기면 Lax도 속습니다.


에이전트가 남의 예약을 지운 밤은 대기자 API 글의 인가 칸입니다. 여기는 브라우저가 쿠키를 실어 보내는 자리입니다. 두 표를 섞지 않습니다. 상태 변경 주소는 조회와 동사를 나누고, 주소창에 붙여 넣을 수 있는 링크로 삭제나 결제가 실행되지 않게 둡니다. 폼과 서버 액션만 쓰는 작은 앱도 허용 목록 한 줄은 남깁니다.


상태 변경은 허용 오리진과 Fetch 메타를 같이 본다
const ALLOWED = new Set([ 'https://www.example.com', 'https://api.example.com', ]) export function assertCsrf(req) { const site = req.headers['sec-fetch-site'] if (site === 'same-origin' || site === 'same-site' || site === 'none') { return true } const origin = req.headers.origin if (origin && ALLOWED.has(origin)) return true return false }
SameSite Lax Strict None과 HttpOnly Secure를 칸으로 나눈 쿠키 속성 비교 그림
세션은 Lax와 HttpOnly가 기본이고, None은 Secure와 같은 출처 BFF를 먼저 검토한다

참고 자료


브라우저 기본값과 카카오 콘솔 문구는 수시로 바뀝니다. 위 표는 2026년 8월 공개 페이지 기준 점검용이며, 적용의 최종 근거는 공식 화면입니다. 운영에 올리기 전 크롬, 사파리, 아이폰 사파리에서 로그인, 외부 링크 진입, 콜백 복귀 세 경로를 한 번씩 눌러 쿠키 줄이 같은지 확인합니다.


자주 묻는 질문

세션을 그냥 None과 Secure로 열면 되나?

도메인을 합칠 수 있으면 열지 않습니다. None은 사파리와 사용자 설정 크롬에서 다시 막힙니다. www와 api를 같은 등록 도메인에 두거나 넥스트를 BFF로 두면 Lax로 충분합니다. 임베드 위젯처럼 진짜 서드파티일 때만 None과 Secure, 필요하면 Partitioned를 같이 둡니다.


로컬스토리지에 액세스 토큰만 두면 사파리 문제가 사라지나?

크로스 사이트 쿠키 문제는 사라집니다. XSS가 토큰을 읽는 문제가 생깁니다. 액세스 토큰은 메모리, 리프레시는 HttpOnly 쿠키가 기본입니다. 사파리 증상을 피하려고 둘 다 로컬스토리지에 두면 다른 사고가 납니다.


localhost와 127.0.0.1을 섞어도 되나?

안 됩니다. 둘은 다른 사이트입니다. 프론트와 API를 둘 다 localhost에 포트를 나눠 두거나, 로컬 호스트 이름을 통일합니다. 쿠키 Domain에 localhost를 적는 것도 브라우저마다 거절합니다. Domain은 빼고 Host-only로 둡니다.


Lax면 CSRF 토큰은 빼도 되나?

상태 변경이 POST이고 출처가 하나여도, GET 변경과 None 예외와 하위 도메인 공유가 남습니다. 오와스프는 토큰이나 오리진 검사를 같이 두라고 적습니다. 서버 액션만 쓰는 작은 앱도 Origin과 Sec-Fetch-Site를 거르는 편이 안전합니다.


아이폰 사파리와 맥 사파리 증상이 다르면?

아이폰이 더 빡센 추적 방지를 쓰는 경우가 많습니다. 같은 아이클라우드 계정, 비공개 릴레이, 크로스 사이트 추적 방지를 확인합니다. 맥 크롬만 보고 통과시키지 않습니다. 실기에서 콜백 직후 애플리케이션 탭의 쿠키 칸을 캡처합니다.


프리뷰 배포마다 CORS를 열어야 하나?

와일드카드 오리진은 자격 증명과 같이 못 갑니다. 프리뷰를 preview.example.com처럼 고정 서브도메인으로 두거나, 허용 목록에 프리뷰 호스트를 명시합니다. 운영 쿠키 Domain을 넓혀 프리뷰와 공유하지 않습니다. 쿠키 이름을 스테이징과 운영으로 나눕니다.


쿠키SameSiteCSRF사파리HttpOnly프론트엔드보안APINext.js개발자브라우저카카오 로그인

함께 보면 좋은 문제 해결

EXPLORE / Frontend

이어서 읽어보기

전체 토픽 둘러보기