매일 업데이트
2022-11-10 08:37 13 min

토큰을 보호하기 위해 HTTPOnly 쿠키로 CORS를 활성화하는 방법은 무엇입니까?

본 문서에서는 HTTPOnly 쿠키를 이용하여 CORS(Cross-Origin Resource Sharing)를 설정하고 액세스 토큰을 안전하게 관리하는 방법에 대해 자세히 알아봅니다.

최근 웹 개발 환경에서는 백엔드 서버와 프론트엔드 클라이언트가 서로 다른 도메인에 분리되어 배포되는 경우가 많습니다. 이러한 구조에서는 클라이언트가 웹 브라우저를 통해 서버와 원활하게 통신할 수 있도록 서버 측에서 CORS를 필수로 활성화해야 합니다.

또한, 서버는 확장성 향상을 위해 상태를 저장하지 않는 인증 방식을 채택하고 있습니다. 토큰은 클라이언트 측에 저장 및 관리되지만, 세션과 같이 서버 측에 저장되지 않습니다. 이때, 보안을 강화하기 위해 HTTPOnly 쿠키에 토큰을 저장하는 것이 권장됩니다.

교차 출처 요청이 차단되는 이유

만약 프론트엔드 애플리케이션이 https://app.koreantech.org.com에 배포되었다고 가정해 봅시다. 이 도메인에서 로드된 스크립트는 동일한 출처의 리소스만을 요청할 수 있습니다.

다른 도메인(예: https://api.koreantech.org.com), 다른 포트(예: https://app.koreantech.org.com:3000), 또는 다른 체계(예: http://app.koreantech.org.com)로의 교차 출처 요청은 브라우저에 의해 차단됩니다.

흥미로운 점은, 브라우저가 차단하는 이러한 요청이 컬(curl)과 같은 도구나 Postman과 같은 API 테스팅 도구를 사용하여 백엔드 서버에 보내면 CORS 문제 없이 정상적으로 처리된다는 것입니다. 그 이유는 브라우저가 CSRF(Cross-Site Request Forgery) 공격과 같은 위협으로부터 사용자를 보호하기 위해 교차 출처 요청을 제한하는 보안 메커니즘을 적용하기 때문입니다.

예를 들어, 사용자가 브라우저를 통해 자신의 PayPal 계정에 로그인한 상태라고 가정해 보겠습니다. 만약 CORS 오류나 차단 없이, 사용자의 브라우저에 로드된 악성 스크립트가 paypal.com으로 교차 출처 요청을 보내 사용자 계좌에서 공격자 계좌로 돈을 이체할 수 있다면 심각한 문제가 발생할 것입니다.

공격자는 짧은 URL을 사용하여 실제 URL을 숨긴 후, 사용자를 악성 페이지(예: https://malicious.com/transfer-money-to-attacker-account-from-user-paypal-account)로 유도할 수 있습니다. 사용자가 이 링크를 클릭하면 악성 사이트에 로드된 스크립트가 PayPal에 교차 출처 요청을 보내 사용자의 동의 없이 돈을 공격자의 PayPal 계정으로 전송할 수 있습니다. PayPal 계정에 로그인한 상태에서 악성 링크를 클릭한 사용자는 순식간에 돈을 잃게 됩니다.

이러한 이유로, 브라우저는 교차 출처 요청을 기본적으로 차단합니다.

CORS(Cross-Origin Resource Sharing)란 무엇인가?

CORS는 신뢰할 수 있는 도메인에서 교차 출처 요청을 허용하도록 브라우저에 지시하기 위해 서버가 사용하는 헤더 기반 보안 메커니즘입니다. 서버는 CORS 헤더를 활성화하여 브라우저에 의해 차단되는 교차 출처 요청을 허용할 수 있습니다.

CORS 작동 방식

서버는 CORS 설정에서 신뢰할 수 있는 도메인을 미리 정의합니다. 클라이언트가 서버에 요청을 보내면, 서버는 응답 헤더에 요청을 보낸 도메인이 신뢰할 수 있는지 여부를 명시합니다.

CORS 요청은 크게 두 가지 유형으로 나뉩니다.

  • 단순 요청
  • 프리플라이트 요청

단순 요청:

  • 브라우저는 요청을 보낼 때 출처(예: https://app.koreantech.org.com)를 교차 출처 도메인에 포함하여 요청을 보냅니다.
  • 서버는 허용된 메서드와 출처를 포함한 응답을 반환합니다.
  • 브라우저는 응답을 받은 후, 요청 시 보낸 출처 헤더 값과 응답에서 받은 access-control-allow-origin 값이 동일한지 확인합니다. 만약 값이 일치하지 않으면 CORS 오류가 발생합니다.

프리플라이트 요청:

  • 사용자 정의 요청 매개변수(예: PUT, DELETE 메서드, 사용자 정의 헤더, 다른 콘텐츠 유형)를 포함하는 교차 출처 요청의 경우, 브라우저는 실제 요청을 보내기 전에 안전성을 확인하기 위해 먼저 OPTIONS 메서드를 사용하여 프리플라이트 요청을 전송합니다.

만약 서버가 프리플라이트 요청에 대한 응답(상태 코드 204, 내용 없음)을 보내면, 브라우저는 실제 요청에 대한 접근 제어 허용 매개변수를 확인합니다. 요청 매개변수가 서버에서 허용되면 실제 교차 출처 요청이 전송되고 응답이 수신됩니다.

access-control-allow-origin: * 는 모든 출처에 대한 응답을 허용하지만, 필요한 경우가 아니면 안전하지 않습니다.

CORS 활성화 방법

모든 도메인에 대해 CORS를 활성화하려면 서버 측에서 CORS 헤더를 활성화하여 출처, 메서드, 사용자 정의 헤더, 자격 증명 등을 명시적으로 허용해야 합니다.

  • 브라우저는 서버에서 CORS 헤더를 읽고 요청 매개변수를 확인한 후에만 클라이언트의 실제 요청을 허용합니다.
  • Access-Control-Allow-Origin: 정확한 도메인(예: https://app.geekflate.com, https://lab.koreantech.org.com) 또는 와일드카드를 지정합니다.
  • Access-Control-Allow-Methods: 필요한 HTTP 메서드(예: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)를 허용합니다.
  • Access-Control-Allow-Headers: 특정 헤더만 허용합니다(예: Authorization, csrf-token).
  • Access-Control-Allow-Credentials: 교차 출처 자격 증명(쿠키, 인증 헤더)을 허용하는 데 사용되는 부울 값입니다.

Access-Control-Max-Age: 브라우저에 프리플라이트 응답을 일정 시간 동안 캐시하도록 지시합니다.

Access-Control-Expose-Headers: 클라이언트 측 스크립트에서 액세스할 수 있는 헤더를 지정합니다.

Apache 및 Nginx 웹 서버에서 CORS를 활성화하는 자세한 방법은 해당 튜토리얼을 참고하십시오.

const express = require('express');
const app = express()

app.get('/users', function (req, res, next) {
  res.json({msg: 'user get'})
});

app.post('/users', function (req, res, next) {
    res.json({msg: 'user create'})
});

app.put('/users', function (req, res, next) {
    res.json({msg: 'User update'})
});

app.listen(80, function () {
  console.log('CORS-enabled web server listening on port 80')
})

ExpressJS에서 CORS 활성화하기

먼저, CORS가 설정되지 않은 ExpressJS 앱의 예시를 살펴보겠습니다.

npm install cors

위의 예시에서는 POST, PUT, GET 메서드에 대한 사용자 API 엔드포인트를 활성화했지만, DELETE 메서드는 활성화하지 않았습니다.

ExpressJS 앱에서 CORS를 쉽게 활성화하려면 cors 패키지를 설치하면 됩니다.

app.use(cors({
    origin: '*'
}));

access-control-allow-origin 설정

app.use(cors({
    origin: 'https://app.koreantech.org.com'
}));

모든 도메인에 대해 CORS를 활성화합니다.

app.use(cors({
    origin: [
        'https://app.geekflare.com',
        'https://lab.geekflare.com'
    ]
}));

단일 도메인에 대해 CORS를 활성화합니다.

만약 https://app.koreantech.org.comhttps://lab.koreantech.org.com에서 들어오는 요청에 대한 CORS를 허용하려면 다음과 같이 설정합니다.

app.use(cors({
    origin: [
        'https://app.geekflare.com',
        'https://lab.geekflare.com'
    ],
    methods: ['GET', 'PUT', 'POST']
}));

액세스 제어 허용 방법 설정

모든 메서드에 대해 CORS를 활성화하려면 ExpressJS의 CORS 모듈에서 이 옵션을 생략하면 됩니다. 만약 특정 메서드(예: GET, POST, PUT)만 활성화하려면 다음과 같이 지정합니다.

app.use(cors({
    origin: [
        'https://app.geekflare.com',
        'https://lab.geekflare.com'
    ],
    methods: ['GET', 'PUT', 'POST'],
    allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token']
}));

액세스 제어 허용 헤더 설정

기본 헤더 외에 다른 헤더도 요청에 포함하여 전송하도록 허용하는 데 사용됩니다.

app.use(cors({
    origin: [
        'https://app.geekflare.com',
        'https://lab.geekflare.com'
    ],
    methods: ['GET', 'PUT', 'POST'],
    allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
    credentials: true
}));

액세스 제어 허용 자격 증명 설정

만약 withCredentialstrue로 설정되었더라도 요청 시 자격 증명을 허용하도록 브라우저에 지시하지 않으려면 이 설정을 생략해야 합니다.

app.use(cors({
    origin: [
        'https://app.geekflare.com',
        'https://lab.geekflare.com'
    ],
    methods: ['GET', 'PUT', 'POST'],
    allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
    credentials: true,
    maxAge: 600 
}));

액세스 제어 최대 연령 설정

브라우저에게 프리플라이트 요청 응답을 지정된 시간(초) 동안 캐시하도록 지시합니다. 응답을 캐시하지 않으려면 이 설정을 생략하십시오.

app.use(cors({
    origin: [
        'https://app.geekflare.com',
        'https://lab.geekflare.com'
    ],
    methods: ['GET', 'PUT', 'POST'],
    allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
    credentials: true,
    maxAge: 600,
    exposedHeaders: ['Content-Range', 'X-Content-Range']
}));

캐시된 프리플라이트 응답은 브라우저에서 10분 동안 사용할 수 있습니다.

app.use(cors({
    origin: [
        'https://app.geekflare.com',
        'https://lab.geekflare.com'
    ],
    methods: ['GET', 'PUT', 'POST'],
    allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
    credentials: true,
    maxAge: 600,
    exposedHeaders: ['*', 'Authorization', ]
}));

액세스 제어 노출 헤더 설정

와일드카드를 사용하면

exposedHeaders 설정에서는 Authorization 헤더가 기본적으로 노출되지 않으므로, 명시적으로 노출해야 합니다.

위 설정에서는 모든 헤더와 Authorization 헤더를 함께 노출합니다.

  • HTTP 쿠키란 무엇인가?
  • 쿠키는 서버가 클라이언트 브라우저에 전송하는 작은 데이터 조각입니다. 브라우저는 이후의 요청에서 동일한 도메인과 관련된 모든 쿠키를 서버에 다시 전송합니다.
  • 쿠키에는 쿠키의 동작을 제어하는 다양한 속성이 있습니다.
  • 이름: 쿠키의 이름입니다.
  • 값: 쿠키 이름에 해당하는 데이터입니다.
  • 도메인: 쿠키가 전송될 도메인입니다.
  • 경로: 쿠키가 전송될 URL 접두사 경로입니다. 예를 들어 쿠키 경로가 path='admin/'로 설정된 경우, https://koreantech.org.com/expire/로의 요청에는 쿠키가 전송되지 않지만, https://koreantech.org.com/admin/로 시작하는 URL의 요청에는 쿠키가 전송됩니다.
  • Max-Age/Expires (초 단위 숫자): 쿠키의 만료 시점입니다. 이 값이 설정되면 지정된 시간이 지난 후 쿠키가 자동으로 무효화됩니다.
  • HTTPOnly (Boolean): true로 설정하면 백엔드 서버에서만 해당 쿠키에 접근할 수 있고, 클라이언트 측 스크립트에서는 접근할 수 없습니다.
  • secure (Boolean): true로 설정하면 SSL/TLS를 통해 암호화된 연결을 통해서만 쿠키가 전송됩니다.
  • sameSite (문자열): 사이트 간 요청에서 쿠키 전송을 활성화하거나 제한하는 데 사용됩니다. 상세 정보는 MDN 문서를 참조하십시오. Strict, Lax, None 세 가지 옵션이 있습니다. sameSite=None 설정을 위해서는 쿠키의 secure 값을 true로 설정해야 합니다.

토큰에 HTTPOnly 쿠키가 필요한 이유

서버에서 발행한 액세스 토큰을 로컬 스토리지, IndexedDB, 또는 HTTPOnly 속성이 설정되지 않은 쿠키와 같은 클라이언트 측 저장소에 저장하면 XSS(Cross-Site Scripting) 공격에 매우 취약해집니다. 만약 웹페이지 중 하나가 XSS 공격에 취약해지면, 공격자는 브라우저에 저장된 사용자 토큰을 오용하여 사용자의 계정에 접근할 수 있습니다.

HTTPOnly 쿠키는 서버 측에서만 설정하고 접근할 수 있으며, 클라이언트 측에서는 설정하거나 접근할 수 없습니다.

  • 클라이언트 측 스크립트는 HTTPOnly 쿠키에 접근이 제한되므로, XSS 공격으로부터 안전합니다. HTTPOnly 쿠키는 서버에서만 접근할 수 있어 더 안전하게 토큰을 관리할 수 있습니다.
  • CORS를 사용하는 백엔드에서 HTTPOnly 쿠키를 사용하기 위해서는 다음과 같은 구성이 필요합니다.
  • Access-Control-Allow-Credentials 헤더를 true로 설정해야 합니다.

Access-Control-Allow-OriginAccess-Control-Allow-Headers는 와일드카드가 아니어야 합니다.

const express = require('express');
const app = express();
const cors = require('cors');

app.use(cors({
  origin: [
    'https://app.geekflare.com',
    'https://lab.geekflare.com'
  ],
  methods: ['GET', 'PUT', 'POST'],
  allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
  credentials: true,
  maxAge: 600,
  exposedHeaders: ['*', 'Authorization' ]
}));

app.post('/login', function (req, res, next) {
  res.cookie('access_token', access_token, {
    expires: new Date(Date.now() + (3600 * 1000 * 24 * 180 * 1)), //second min hour days year
    secure: true, // set to true if your using https or samesite is none
    httpOnly: true, // backend only
    sameSite: 'none' // set to none for cross-request
  });

  res.json({ msg: 'Login Successfully', access_token });
});

app.listen(80, function () {
  console.log('CORS-enabled web server listening on port 80')
});

쿠키의 sameSite 속성은 none으로 설정해야 합니다.

sameSite 값을 none으로 활성화하려면 secure 값을 true로 설정해야 합니다. SSL/TLS 인증서가 설치된 백엔드 서버의 도메인에서만 작동합니다.

로그인 자격 증명 확인 후 HTTPOnly 쿠키에 액세스 토큰을 설정하는 예시 코드를 살펴보겠습니다.

백엔드 언어와 웹 서버 환경에 따라 위의 4가지 단계를 구현하여 CORS 및 HTTPOnly 쿠키를 설정할 수 있습니다.

var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://api.koreantech.org.com/user', true);
xhr.withCredentials = true;
xhr.send(null);

CORS 활성화를 위해 Apache와 Nginx 웹 서버 설정을 위한 상세 정보는 해당 가이드를 참고하십시오.

fetch('http://api.koreantech.org.com/user', {
  credentials: 'include'
});

교차 출처 요청을 위한 withCredentials 설정

$.ajax({
   url: 'http://api.koreantech.org.com/user',
   xhrFields: {
      withCredentials: true
   }
});

기본적으로 자격 증명(쿠키, 인증 헤더)은 동일 출처 요청에만 전송됩니다. 교차 출처 요청의 경우 withCredentialstrue로 명시적으로 설정해야 합니다.

axios.defaults.withCredentials = true

XMLHttpRequest API

Fetch API

jQuery AJAX

Axios

결론적으로, 이 글이 CORS의 동작 방식을 이해하고 서버에서 교차 출처 요청을 처리하기 위한 CORS 설정을 이해하는 데 도움이 되었기를 바랍니다. 또한 HTTPOnly 쿠키에 토큰을 저장하는 것이 왜 더 안전한지, 그리고 교차 출처 요청을 위해 클라이언트에서 withCredentials가 어떻게 사용되는지 명확히 이해할 수 있기를 바랍니다.

저자
Korea

기술 트렌드와 실용적인 팁을 전하는 लेखक입니다.