쿠키는 브라우저에 데이터를 저장하기 위한 수단 중 하나이다. 브라우저에서 서버로 요청을 전송할 때 그 요청에 대한 응답에 Set-Cookie 헤더가 포함되어 있는 경우, 브라우저는 Set-Cookie에 있는 데이터를 저장하고, 이 저장된 데이터를 쿠키라고 부른다.
필자의 경우 로그인 기능 구현시 사용자 인증 후 쿠키에 토큰을 저장해주었다. (쿠키 옵션 설정에 대해선 밑에서 다룰 것이기 때문에 잠시 설정된 옵션들은 제거함)
const login=(req,res)=>{
// do something...
return res.status(200).cookie("x_auth", token).json({ success: true });
}
이렇게 res.cookie("쿠키 이름", 데이터 [, 옵션])
를 하게 되면, 개발자 도구> Network > Response Headers에서 다음과 같이 Set-Cookie 헤더가 포함된 것을 확인할 수 있다.
위처럼 서버의 응답에 Set-Cookie 헤더가 포함된 경우, 설정된 이름(x_auth)의 쿠키에 토큰이 저장된다.
그리고 이렇게 저장된 쿠키는 다음에 다시 그 브라우저에서 서버로 요청을 보낼 때, Cookie라는 헤더에 같이 전송된다.
개발자 도구> Network > Request Headers
서버에서는 이제 이 헤더를 읽어서(req.cookies.x_auth) 사용자를 식별하기 위한 수단으로 사용할 수 있다.
쿠키에 도메인을 설정하면 해당 도메인에서만 유효한 쿠키가 된다. 별도의 명시하지 않으면 기본값으로 쿠키를 보낸 서버의 도메인으로 설정된다.
return res
.status(200)
.cookie("x_auth", token, {
domain:"도메인"
})
.json({ success: true });
이렇게 설정된 도메인을 기준으로 First-party cookies와 Third-party cookies가 나뉜다.
이제 쿠키가 무엇인지 알았으니 서드 파티 쿠키를 어떻게 전송할 수 있을지에 대해서 알아보자.
CORS 개념 정리(preflight, simple, credentialed request)+SOP편에서도 언급했듯이 기본적으로 브라우저에서 제공하는 비동기 리소스 요청 API인 XMLHttpRequest 객체나 fetch API는 별도의 옵션 없이 브라우저의 쿠키 정보나 인증과 관련된 헤더를 함부로 request에 담지 않는다.
구글 크롬 브라우저의 경우 credentials 기본값이 same-origin이기 때문에, 포트 3000에서 4000으로 보내는 리소스 요청에 쿠키는 담기지 않는다.
이럴 때 요청/응답 헤더를 적절하게 설정해주면 다른 도메인이더라도 쿠키를 전송할 수 있다.
먼저 프론트에서 AJAX 요청을 보낼 때 withCredentials를 설정해줘야 한다.
axios.get(url, {withCredentials:true});
fetch(url, {
credentials: 'include'
})
그리고 서버에서는 CORS 설정을 해줘야한다. React + Express | CORS 설정하기편을 참고하도록 하자.
여기에 응답 헤더로 Access-Control-Allow-Credentials 옵션을 true로 설정해주고, Access-Control-Allow-Origin 옵션도 *가 아닌 정확한 도메인을 적어주면 쿠키가 전송된다. 응답에서 리소스와 함께 이 헤더가 없다면 브라우저는 응답을 무시하고 웹 콘텐츠를 전달하지 않으니 주의하자.
Express를 사용하는 경우 cors 모듈로 쉽게 설정할 수 있다.
$ npm i cors
import express from "express";
import cors from 'cors';
const app = express();
app.use(cors({ origin: 'http://localhost:3000', credentials: true }));
cors 모듈을 사용하지 않는다면 라우터에서 응답 헤더를 직접 넣어주면 된다.
res.setHeader('Access-Control-Allow-Origin', 'localhost:3000');
res.setHeader('Access-Control-Allow-Credentials', 'true');
여기까지가 구글링하면 공통적으로 찾을 수 있는 해결 방법이다.
하지만 필자의 경우 이렇게 설정했음에도 배포 이후 서버 주소로의 요청에 브라우저 쿠키가 안들어가는 문제가 발생했었다. 추측되는 원인으로는 크롬 80버전이 배포되면서 브라우저 쿠키의 SameSite 기본값이 None에서 Lax로 변경된 점이다. 이에 cookie에 SameSite 관련 추가적인 설정을 통해 해결하였다.
어떻게 설정해야 하는지 정리하기 전에, 먼저 쿠키에 SameSite가 생기게 된 배경을 살펴보자.
쿠키에 별도로 설정을 가하지 않으면, 크롬을 제외한 브라우저들은 모든 HTTP 요청에 대해 쿠키를 전송한다. 이러한 특성을 노린 공격이 CSRF(Cross Site Request Forgery)이다.
CSRF 공격 원리를 정리하자면 다음과 같다.
SameSite 쿠키는 앞서 언급한 서드파티 쿠키의 보안적 문제를 해결하기 위해 만들어진 기술이다. 이는 크로스 사이트(Cross-site)로 전송하는 요청의 경우 쿠키의 전송에 제한을 둔다.
SameSite 쿠키 정책으로 None, Lax, Strict가 있다. 셋 다 퍼스트 파티 쿠키에 대해선 동일하게 항상 전송하지만, 서드 파티 쿠키에 대해서 차이가 있다.
None
: 크로스 사이트 요청의 경우에도 항상 전송한다. SameSite가 탄생하기 전과 동일하게 동작.Strict
: 크로스 사이트 요청에는 항상 쿠키를 전송하지 않는다.Lax
: Strict에 비해 느슨한 정책. 대체로 서드파티 쿠키를 전송하지 않지만, 몇 가지 예외적인 요청에는 전송한다.Lax 쿠키 예외 사항 : Top Level Navigation(웹 페이지 이동)과, "안전한" HTTP 메서드 요청의 경우 전송된다.
여기서 Top Level Navigation은 <a>
클릭, window.location.replace
등 자동으로 이뤄지는 이동, 302 redirect를 이용한 이동이 포함된다. 그리고 "안전하지 않은" 요청은 POST나 DELETE 같은 요청을 말한다. (GET은 OK)
원래 SameSite를 명시하지 않은 쿠키는 None으로 동작했지만, 2020년 2월 4일 크롬 80 버전이 배포되면서 SameSite의 기본값이 Lax로 변경되었다. 이에 따라 위에서 언급했던 것처럼 예외사항에 포함되지 않으면 다른 도메인 간의 요청에서 쿠키를 담아주지 않는다.
이를 해결하기 위해선 SameSite 속성을 None으로 변경하고 쿠키를 secure 쿠키로 만들어 주면
된다. (SameSite를 None으로 하려면 무조건 secure를 true로 설정해야만 정상적으로 전송된다.)
const login=(req,res)=>{
// do something...
return res
.status(200)
.cookie("x_auth", token, {
httpOnly: true, //XSS공격을 막기위해 추가로 설정한 것
sameSite: 'none',
secure: true
})
.json({ success: true });
}
이렇게까지 해주면 서로 다른 도메인을 가지는 프론트와 서버 사이에서 쿠키를 주고 받을 수 있게 된다.