PHP 8.6은 세션 쿠키에 HttpOnly·SameSite=Lax를 붙인다 — 11월 19일 전에 확인할 세 가지

  • 9th September 2026
  • 8 min read

PHP 8.6이 2026년 11월 19일에 나온다. 세션 확장의 ini 기본값 셋이 바뀐다. 코드를 한 줄도 고치지 않아도 세션 쿠키에 붙는 속성이 달라진다.

같은 코드에 같은 요청을 보내고 응답 헤더만 비교하면 이렇다.

# PHP 8.5.4
Set-Cookie: PHPSESSID=75586de12bb15eb5…; path=/

# PHP 8.6.0beta2
Set-Cookie: PHPSESSID=d6522d741bd160b6…; path=/; HttpOnly; SameSite=Lax

바뀌는 값은 셋이다.

설정 8.5까지 8.6부터 막는 것
session.use_strict_mode01세션 고정
session.cookie_httponly01스크립트로 쿠키 탈취
session.cookie_samesite빈 값LaxCSRF

"Secure Session Configuration Defaults" RFC가 2026년 5월 17~18일 투표를 통과했다. 세 항목 각각 27:0, 26:0(기권 1), 26:0으로 반대표가 하나도 없었다.

내 서버가 지금 어느 쪽인지

php.ini에서 이미 손봐 둔 서버라면 아무 일도 일어나지 않는다. 먼저 확인부터 한다.

php -r 'foreach (["session.use_strict_mode","session.cookie_httponly",
  "session.cookie_samesite","session.cookie_secure"] as $k)
  printf("%-28s %s\n", $k, var_export(ini_get($k), true));'

'0'과 빈 문자열이 나오면 기본값 그대로다. 업그레이드하면 값이 바뀐다. 세 줄이 이미 '1'·'1'·'Lax'면 8.6에서도 그대로다.

기본값 그대로인 서버가 흔하다. 세션 설정은 한 번 돌아가면 다시 들여다볼 일이 없어서, php.ini를 배포 시점에 만들고 그대로 두는 경우가 많다.

왜 지금 바꾸는가

RFC의 첫 문장이 이유를 그대로 말한다.

PHP 세션 확장의 세 ini 설정은 보안에 영향을 주는데, 애플리케이션을 불필요하게 노출시키는 기본값으로 배포된다.

셋 다 안전한 값이 무엇인지 논쟁이 없는 설정이다. HttpOnly는 2006년에 도입된 뒤 OWASP·Mozilla·NIST가 줄곧 권고해 왔고, RFC는 "기본을 끄는 정당한 이유가 없다"고 적는다. 그런데도 20년 동안 기본이 꺼져 있었던 것은 순전히 하위 호환 때문이다.

RFC는 다른 플랫폼과의 비교표도 싣는다. ASP.NET Core, Django, Rails, Laravel, Symfony가 이미 HttpOnly를 켜고 SameSite=Lax를 기본으로 둔다. PHP만 안전하지 않은 기본값으로 남아 있었다. 프레임워크를 쓰면 프레임워크가 대신 켜주지만, 프레임워크 없이 session_start()만 쓰는 코드는 그 보호를 못 받는다.

세션 고정이 실제로 어떻게 성립하는가

셋 중 use_strict_mode가 가장 설명이 필요하다. 이름만 봐서는 무엇이 엄격해지는지 알기 어렵다.

세션 고정은 공격자가 자기가 아는 세션 ID를 미리 심어두고 피해자가 그 ID로 로그인하기를 기다리는 공격이다. 피해자가 로그인에 성공하면, 공격자는 처음부터 알고 있던 그 ID로 같은 세션에 들어간다. 비밀번호를 훔치지 않고 로그인된 상태를 가져간다.

① ID 를 심는다 공격자가 아는 값으로 ② 피해자가 로그인 심긴 ID 를 그대로 보냄 ③ 서버가 그 ID 를 수용 로그인 상태를 그 ID 에 저장 ④ 공격자가 들어간다 같은 ID · 로그인된 세션 ③ 에서 갈린다 — 실측 PHP 8.5 · use_strict_mode=0 보낸 ID forged85000000… 실제 ID forged85000000… Set-Cookie 없음 — 그대로 씀 sess_forged85000000… 파일 생성 PHP 8.6 · use_strict_mode=1 보낸 ID forged86000000… 실제 ID cf0557186c3730… (새 값) Set-Cookie 로 교체 · 위조 ID 거부 위조 ID 로는 파일이 생기지 않음
같은 위조 세션 ID를 쿠키로 보냈을 때 — 8.5.4와 8.6.0beta2 실측

존재하지 않는 세션 ID forged…를 쿠키에 담아 보내 봤다. 8.5는 그 ID를 그대로 받아들이고 그 이름으로 세션 파일까지 만든다. 공격자가 심은 값이 진짜 세션이 되는 것이다. 8.6은 거부하고 새 ID를 발급해 Set-Cookie로 내려준다. 위조 ID로는 세션 파일이 만들어지지 않는다.

여기서 한 가지 오해를 짚어야 한다. use_strict_mode는 이미 존재하는 세션 ID는 거부하지 않는다. 검사하는 것은 "이 ID가 저장소에 실제로 있는가"다. 8.6에서 방금 발급한 ID를 다시 보내면 그대로 받아들인다. 막는 것은 아무도 만들지 않은 ID다.

그리고 검사 대상은 클라이언트가 보낸 ID다. 서버 코드가 session_id('…')로 직접 지정한 값은 검사하지 않는다. 개발자가 의도적으로 지정한 것으로 보기 때문이다. 이 차이 때문에 CLI에서 session_id()로 테스트하면 strict mode가 동작하지 않는 것처럼 보인다.

무엇이 깨지는가 — 크로스사이트 POST

가장 크게 걸리는 자리다. SameSite=Lax는 다른 사이트에서 넘어오는 요청 중 안전한 메서드의 최상위 내비게이션에만 쿠키를 붙인다.

다른 사이트에서 온 요청 세션 쿠키가 붙나
링크 클릭 (GET, 주소창이 바뀜)붙는다
리다이렉트 (GET)붙는다
폼 전송 (POST)안 붙는다
<iframe> 안의 이동안 붙는다
fetch() · XHR안 붙는다
<img> · <script> 하위 리소스안 붙는다

문제가 되는 대표적인 흐름이 POST로 돌아오는 인증 콜백이다. OAuth에서 response_mode=form_post를 쓰는 공급자, SAML의 HTTP-POST 바인딩이 그렇다. 인증 서버가 브라우저에게 "이 폼을 우리 콜백 주소로 POST하라"고 시키는 방식이다.

이때 콜백은 다른 사이트에서 온 POST다. 세션 쿠키가 붙지 않으니 서버는 세션을 못 찾는다. state 값을 세션에 넣어 두고 콜백에서 대조하는 구현이라면 "state mismatch"로 실패한다. 로그인 직전에 세션이 사라진 것처럼 보인다.

지금까지 잘 돌았는데 왜 8.6에서 깨지나

여기가 중요하다. 최근 브라우저는 SameSite없는 쿠키에도 Lax를 기본 적용해 왔다. 그런데 그때 적용되는 Lax는 더 관대한 판본이다. Chromium 문서의 설명은 이렇다.

"Lax + POST"는 Lax-by-default 쿠키(SameSite 속성을 지정하지 않은 쿠키)에 대한 완화 조치로, 쿠키가 2분 이내에 설정된 것이라면 최상위 크로스사이트 POST 요청에도 전송되도록 허용한다.

이 2분 완화가 서버가 SameSite=Lax를 명시한 쿠키에는 적용되지 않는다. 명시된 Lax는 완화 없이 엄격하게 적용된다.

PHP 8.5까지는 session.cookie_samesite가 빈 값이라 Set-CookieSameSite아예 붙지 않았다. 그래서 브라우저가 Lax-by-default로 처리하며 2분 완화를 줬고, 로그인 흐름은 대개 2분 안에 끝나니 POST 콜백이 통과했다. 8.6은 SameSite=Lax명시적으로 보낸다. 완화가 사라진다.

"우리는 SameSite를 안 건드렸는데 잘 돌아갔다"가 안전의 근거가 아니었다. 브라우저의 임시 완화에 얹혀 있었을 뿐이고, 그 완화는 Chromium 문서가 "임시"라고 명시한 것이라 언젠가는 사라진다. 8.6이 그 시점을 앞당기는 셈이다.

무엇이 깨지는가 — 서브도메인 세션 공유와 스크립트

서브도메인 사이에 세션 ID를 넘겨 쓰는 구조use_strict_mode에 걸린다. 한쪽에서 만든 ID를 다른 쪽에 URL이나 쿠키로 전달하는 방식인데, 받는 쪽에서 그 ID로 세션을 열 때 저장소에 실제로 없으면 거부된다.

RFC의 처방은 명확하다.

올바른 접근은 ID를 받는 쪽에 넘기기 전에, 만든 쪽에서 세션을 쓰고 닫는 것(session_write_close())이다.

세션을 열어만 두고 아무것도 저장하지 않으면 저장소에 파일이 안 생긴다. 그 상태로 ID를 넘기면 받는 쪽에서 "없는 ID"가 되어 거부된다. 넘기기 전에 session_write_close()를 호출해 실체를 만들어 두면 8.6에서도 그대로 동작한다. 앞의 실측에서 확인한 "이미 존재하는 ID는 받아들인다"가 이것이다.

HttpOnly 쪽은 걸리는 범위가 좁지만 확실하다. document.cookie로 세션 쿠키를 읽던 코드가 전부 멈춘다. 세션 ID를 꺼내 CSRF 토큰으로 쓰거나, 로그인 여부를 쿠키 존재로 판단하던 스크립트가 해당된다. RFC는 세션 쿠키 자체를 쓰지 말고 별도의 non-HttpOnly 토큰을 두라고 권한다.

어떻게 대응하는가

핵심은 하나다. 업그레이드를 기다리지 말고 지금 명시해 둔다. 그러면 8.6에서 기본값이 바뀌어도 동작이 달라지지 않고, 명시하는 순간 깨질 것이 지금 깨져서 미리 고칠 수 있다.

// session_start() 앞에서
session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'domain'   => '',
    'secure'   => true,      // HTTPS 라면 반드시
    'httponly' => true,
    'samesite' => 'Lax',
]);
ini_set('session.use_strict_mode', '1');
session_start();

이 코드를 PHP 8.5.4와 8.6.0beta2에서 각각 돌려 응답 헤더를 비교해 보면 같은 Set-Cookie가 나온다. 명시해 두면 버전이 무엇이든 결과가 같아진다는 뜻이다.

# 8.5.4
Set-Cookie: PHPSESSID=ee5345e937d5f65d…; path=/; HttpOnly; SameSite=Lax
# 8.6.0beta2
Set-Cookie: PHPSESSID=83ab9d8e1a7eaabc…; path=/; HttpOnly; SameSite=Lax

php.ini로 해도 된다. 어느 쪽이든 배포 대상 서버 전부에 적용되는지가 중요하다. 서버마다 php.ini가 다른 환경이라면 코드에 두는 편이 확실하다.

POST 콜백이 있다면

인증 콜백이 POST로 돌아오는지 확인한다. OAuth 설정에 response_mode=form_post가 있는지, SAML이 HTTP-POST 바인딩인지 보면 된다. 해당된다면 선택지는 둘이다.

  • 콜백 경로에만 SameSite=None; Secure — 세션 쿠키를 크로스사이트로 보내겠다는 선언이다. HTTPS가 필수고, CSRF 방어를 SameSite에 기대던 부분은 토큰으로 따로 세워야 한다.
  • 세션에 의존하지 않는 state 검증state에 서명된 값을 실어 보내 콜백에서 서명만 검증하는 방식. 세션 쿠키가 없어도 성립한다. RFC가 권하는 "토큰 기반으로 이전"이 이쪽이다.

여기서 실측으로 확인한 함정이 하나 있다. SameSite=NoneSecure가 반드시 함께 있어야 브라우저가 받아들이는데, PHP는 Secure 없는 None을 막지 않는다.

session_set_cookie_params(['samesite' => 'None', 'secure' => false]);

# 8.5.4 · 8.6.0beta2 둘 다 경고 없이 그대로 내보낸다
Set-Cookie: PHPSESSID=dff63e3de2bcadff…; path=/; SameSite=None

서버는 조용하고 브라우저만 쿠키를 버린다. 로그에 아무것도 남지 않으니 "세션이 그냥 안 잡힌다"로 보인다. None을 쓸 때는 secure => true를 반드시 함께 적는다.

급하면 되돌릴 수 있다

업그레이드 후 문제가 터졌을 때의 임시 방편이다. php.ini에서 옛 값으로 돌리면 8.5와 같은 동작이 된다.

session.use_strict_mode = 0
session.cookie_httponly = 0
session.cookie_samesite =

다만 이건 시간을 사는 것일 뿐이다. 되돌린 채로 두면 원래의 노출 상태로 돌아간다. 특히 use_strict_mode = 0은 세션 고정을 다시 열어두는 것이라, 되돌린다면 셋 중 필요한 것만 최소로 되돌리는 편이 낫다.

무엇이 좋아지는가

깨지는 것만 보면 손해처럼 느껴지지만, 얻는 쪽이 분명하다.

  • 세션 고정이 기본으로 막힌다. session_regenerate_id()를 로그인 시점에 부르는 것은 여전히 필요하지만, 그걸 빠뜨린 코드도 최소한 위조 ID를 받아들이지는 않게 된다.
  • XSS의 피해 범위가 줄어든다. 스크립트 삽입이 성공해도 세션 쿠키는 document.cookie로 나가지 않는다. XSS를 막는 것은 아니지만 "스크립트 한 번 = 계정 탈취"의 연결이 끊긴다.
  • CSRF 방어층이 하나 생긴다. 토큰 검증을 대신하지는 않지만, 토큰을 빠뜨린 엔드포인트가 크로스사이트 POST로 호출되는 것을 브라우저 단에서 막아준다.
  • 새로 만드는 코드가 안전한 상태로 시작한다. 지금은 session_start()만 쓴 코드가 세 가지 보호를 다 놓친 상태로 출발한다.
  • 리뷰에서 확인할 항목이 줄어든다. "이 프로젝트 세션 설정 확인했나"가 기본값으로 해결된다.

11월 전에 할 일

  • 지금 값을 확인한다. 위의 한 줄 명령으로 서버마다 확인. 이미 안전한 값이면 할 일이 없다.
  • 기본값 그대로면 지금 명시한다. session_set_cookie_params()httponly·samesite·secure를, use_strict_mode는 ini로. 8.6과 같은 동작이 되므로 문제가 지금 드러난다.
  • 인증 콜백이 POST로 오는지 본다. response_mode=form_post, SAML HTTP-POST 바인딩. 해당되면 콜백 경로만 None; Secure로 열거나 state 검증을 세션에서 떼어낸다.
  • 서브도메인 간 세션 공유를 찾는다. ID를 넘기기 전에 session_write_close()가 있는지 확인.
  • document.cookiePHPSESSID를 읽는 스크립트를 검색한다. 있으면 별도 토큰으로 옮긴다.

세 설정 모두 켜는 방향이 맞고, 논쟁의 여지도 없다. 문제는 언제 켜지는가뿐이다. 11월 19일에 업그레이드와 함께 한꺼번에 겪을지, 지금 한 줄씩 켜면서 나눠 겪을지의 선택이다. 후자가 훨씬 싸다.

참고