블로그로 돌아가기
2026년 10월 7일Sergei Solod15 분 읽기

독립 루트 도메인 간 크로스도메인 SSO 구축 방법

호스트 전용 세션, 알려진 인증 발급자, 수명이 짧은 일회성 핸드오프 토큰을 사용해 모든 방문자를 인증 리디렉션으로 보내지 않고 독립 루트 도메인 전반에 통합 인증을 구현하는 실용적인 아키텍처.

크로스도메인 SSO인증세션 보안웹 보안웹 아키텍처

일반적인 브라우저 격리를 약화하지 않으면서 여러 독립 웹사이트가 하나의 계정 시스템처럼 동작하게 해야 했습니다. 사용자는 한 도메인에서 로그인한 뒤 나중에 다른 도메인을 직접 열고 Log In을 클릭해, 자격 증명을 다시 입력하지 않고도 그 사이트를 계속 사용할 수 있어야 했습니다.

중요한 제약은 이 사이트들이 서로 독립된 루트 도메인이라는 점이었습니다. 한 루트 도메인이 만든 세션 쿠키를 관련 없는 다른 루트 도메인에 그대로 적용할 수는 없습니다. 흔히 뒤섞여 다뤄지는 두 개념, 즉 글로벌 사용자 신원과 로컬 브라우저 세션을 분리해서 생각하자 설계를 훨씬 이해하기 쉬워졌습니다.

결과적인 모델은 단순합니다. 백엔드는 하나의 공유 계정 신원을 인식하고, 각 도메인은 자체적으로 호스트에 바인딩된 세션을 소유합니다. 그리고 수명이 짧은 일회성 핸드오프를 통해 인증된 신뢰 가능한 오리진이 다른 신뢰 가능한 오리진에 새 세션 생성을 승인할 수 있습니다.

글로벌 사용자 신원에 공유 쿠키가 필요한 것은 아니다

브라우저 세션이 로컬이어도 계정 자체는 글로벌일 수 있습니다.

각 사이트는 호스트 전용 세션 쿠키를 유지합니다. 한 루트 도메인이 발급한 쿠키를 Domain 속성으로 관련 없는 루트 도메인까지 확장할 수는 없습니다. 이 속성으로 쿠키 범위를 넓힐 수 있는 것은 발급 호스트가 사용할 수 있도록 허용된 도메인 계층 내부뿐입니다.

한 호스트에 계속 묶여 있어야 하는 세션에는 __Host- 접두사가 붙은 쿠키가 잘 맞습니다. 개념적으로는 다음과 같습니다.

Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=Lax

중요한 것은 다음과 같은 경계입니다.

  • 사이트 A는 사이트 B의 호스트 전용 세션 쿠키를 읽을 수 없습니다.
  • 사이트 B는 사이트 A의 호스트 전용 세션 쿠키를 읽을 수 없습니다.
  • 그래도 두 세션은 같은 백엔드 사용자 신원으로 해석될 수 있습니다.

따라서 크로스도메인 SSO는 한 사이트의 쿠키를 다른 사이트로 옮기려 해서는 안 됩니다. 대신 신뢰할 수 있는 출처가 이미 사용자를 인증했다는 임시 증명을 전달해야 합니다.

일회성 핸드오프 패턴

인증된 출처는 작은 핸드오프 작업을 제공합니다. 브라우저는 사이트 간 인증 전환이 필요할 때만 이 작업을 호출합니다.

백엔드는 암호학적으로 무작위인 티켓을 생성하고 원시 값을 브라우저에 단 한 번 반환합니다. 저장하는 것은 해시와 전송을 검증하는 데 필요한 최소한의 정보뿐입니다.

{
  tokenHash,
  userId,
  sourceOrigin,
  targetSite,
  returnPath,
  expiresAt
}

티켓은 수명이 짧고 하나의 대상에 대해서만 유효해야 합니다. 출처 측 발급 요청은 이미 인증되어 있어야 하며 CSRF 공격에 대해서도 보호되어야 합니다. 요청된 대상과 반환 위치는 무엇이든 발급하기 전에 검증해야 합니다.

그다음 브라우저는 대상에 최상위 POST를 수행합니다. 대상은 티켓을 소비하고 공유 사용자 신원을 확인한 뒤 자체 로컬 세션을 만들고, 브라우저를 의도한 경로로 리디렉션합니다.

개념적으로는 다음과 같습니다.

record = consumeOnce({
  tokenHash: hash(ticket),
  targetSite: currentSite,
  sourceOrigin: requestOrigin,
  notExpired: true
})

if (!record) deny()

user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)

이 티켓 자체가 대상 세션인 것은 아닙니다. 대상 세션을 만들 수 있도록 한 번만 허용하는 권한입니다.

원자적 소비로 재사용 창을 닫는다

더 취약한 구현이라면 티켓을 읽고 검증한 다음 세션을 만들고, 그 뒤에 티켓을 삭제할 것입니다. 그러면 두 요청이 아직 유효한 동일 레코드를 동시에 볼 수 있는 시간이 생깁니다.

더 안전한 설계는 검증과 무효화를 하나의 원자적 소비 작업으로 합치는 것입니다. 일치하는 레코드가 없으면 인증은 실패합니다. 레코드가 반환되었다면 세션을 만들기 전에 그 티켓은 이미 사용 가능한 집합에서 제거된 상태입니다.

이렇게 하면 대상이 기대 대상, 기대 출처 오리진, 토큰 해시, 만료 시점 같은 여러 조건을 하나의 작업에 함께 묶을 수도 있습니다.

Origin 검증은 실패 시 차단되어야 한다

Origin을 바인딩하면 또 하나의 유용한 속성이 생깁니다. 특정 신뢰 출처를 통해 발급된 티켓을 관련 없는 오리진에서 소비하지 못하게 할 수 있습니다.

Origin 검사는 소비 조건 자체에 포함되어야 합니다. 그러면 잘못된 오리진의 요청은 유효한 티켓을 소비하지 않고, 이후 정상 출처가 핸드오프를 계속 완료할 수 있습니다.

여기에는 한 가지 중요한 주의점이 있습니다. Origin 헤더가 모든 브라우저 컨텍스트에서 정상적인 오리진 문자열을 반드시 포함하는 것은 아닙니다. 사용할 수 있는 값 없이 도착하는 요청도 있고, 불투명한 컨텍스트에서는 Origin: null이 만들어질 수도 있습니다.

제 규칙은 애플리케이션에 명시적인 대체 검증 수단이 없는 한, Origin이 없거나 불투명하면 Origin 검사 실패로 처리하는 것입니다. 호환성을 이유로 리터럴 값 null을 신뢰 가능한 오리진으로 간주해서는 안 됩니다.

이는 실패 시 차단하는 선택입니다. 정상 흐름에서 사용할 수 있는 Origin이 없는 컨텍스트를 지원해야 한다면, 일반 검사를 조용히 약화하는 대신 그 예외를 명시적으로 설계해야 합니다.

핸드오프 자격 증명을 URL에 넣지 않는다

저는 임시 티켓을 쿼리 문자열에 넣기보다 최상위 폼 POST로 보내는 방식을 선호합니다.

일반화한 클라이언트 측 버전은 다음과 같습니다.

const form = document.createElement('form');
form.method = 'POST';
form.action = targetOrigin + '/auth/consume';

const input = document.createElement('input');
input.type = 'hidden';
input.name = 'ticket';
input.value = ticket;

form.append(input);
document.body.append(form);
form.submit();

대상은 예상한 POST 본문에서 티켓을 받으며, 동일한 값을 URL을 통해 제공하려는 시도는 거부합니다.

이 방식이 자격 증명을 무해하게 만드는 것은 아니지만, 일반적인 탐색 URL에서는 제외할 수 있습니다. 그 결과 URL 기록, 복사된 링크, 리퍼러, URL 중심 로깅을 통한 우발적 노출을 줄일 수 있습니다.

인증된 사이트 간 링크는 필요할 때 핸드오프로 전환할 수 있다

현재 사이트가 사용자가 이미 인증되었다는 사실을 알고 있다면, 다른 신뢰 사이트로 가는 일반 링크는 필요할 때 핸드오프를 수행할 수 있습니다.

그래도 링크에는 실제 href가 있어야 합니다. SSO는 일반 탐색을 강화하는 기능이지, 그것을 대체하는 기능이 아닙니다.

간소화한 클라이언트 흐름은 다음과 같습니다.

if (session.status !== 'authenticated') {
  return; // ordinary browser navigation
}

if (!isTrustedTarget(destination)) {
  return;
}

event.preventDefault();

const ticket = await requestHandoff({
  targetSite,
  returnPath: destinationPath
});

postTicketToTarget(ticket);

프로덕션 버전에서는 중복 제출도 방지하고, 신뢰 레지스트리를 기준으로 대상을 검증하며, 핸드오프를 만들 수 없을 때는 일반 탐색으로 폴백해야 합니다.

브라우저의 기본 동작은 그대로 유지되어야 합니다. 가운데 클릭, 수정 키를 누른 클릭, 다운로드, 다른 브라우징 컨텍스트를 대상으로 한 링크를 몰래 인증 요청으로 바꾸어서는 안 됩니다.

두 번째 사이트의 Log In 버튼에는 알려진 발급자가 필요하다

더 어려운 경우는 사용자가 사이트 B를 직접 여는 상황에서 시작됩니다. 사이트 B에는 로컬 세션이 없으므로, 브라우저에 사이트 A의 유효한 세션이 남아 있더라도 방문자를 익명으로 취급하는 것이 올바릅니다.

사이트 B는 사이트 A의 호스트 전용 쿠키를 확인할 수 없습니다. 또한 임의의 사이트 C로 리디렉션한 뒤 그 사이트가 시스템의 다른 사이트에 속한 세션을 찾아낼 것이라고 기대할 수도 없습니다. 서로 관련 없는 각 루트 도메인은 자기에게 속한 쿠키만 볼 수 있습니다.

따라서 익명 사용자의 Log In 흐름에는 알려진 발급자가 필요합니다. 정규 인증 오리진이거나, 재사용 가능한 로그인 세션을 보유하고 있을 것으로 예상되는 명시적으로 선택된 신뢰 사이트여야 합니다.

단순한 사이트 A → 사이트 B 사례의 순서는 다음과 같습니다.

  1. 사용자가 사이트 A에 로그인합니다.
  2. 나중에 사용자가 사이트 B를 직접 엽니다.
  3. 사이트 B에는 세션이 없으므로 Log In을 표시합니다.
  4. 사용자가 Log In을 클릭합니다.
  5. 브라우저가 선택된 발급자인 사이트 A로 최상위 탐색을 수행합니다.
  6. 사이트 A는 자기 자신의 기존 호스트 전용 세션 쿠키를 읽을 수 있습니다.
  7. 그 세션이 아직 유효하므로 사이트 A는 즉시 사이트 B용 일회성 핸드오프를 발급합니다.
  8. 브라우저가 티켓을 사이트 B로 POST합니다.
  9. 사이트 B가 자체 로컬 세션을 만들고 사용자를 요청된 페이지로 돌려보냅니다.

사용자 입장에서는 사이트 A로 빠르게 이동했다가 돌아오는 과정일 뿐입니다. 사이트 A에는 자신이 볼 수 있도록 허용된 세션이 이미 있으므로 비밀번호를 다시 입력할 필요가 없습니다.

제약도 그만큼 중요합니다. 사용자가 사이트 C에만 로그인되어 있다면, 사이트 B를 사이트 A로 리디렉션해도 도움이 되지 않습니다. 사이트 A가 로그인 상태를 독립적으로 알고 있는 진정한 중앙 인증 브로커인 경우만 예외입니다. 사용자 데이터베이스를 공유한다고 해서 브라우저 세션까지 공유되는 것은 아닙니다.

정규 발급자가 시스템을 단순하게 만드는 이유

독립 사이트의 수가 늘어날수록 정규 발급자를 두면 임의의 도메인 사이에서 세션을 탐색할 필요가 없어집니다.

정규 발급자가 없다면 익명 상태의 사이트 B는 현재 어느 다른 사이트가 유용한 쿠키를 가지고 있을지 어떤 식으로든 결정해야 합니다. 사이트 A는 사이트 C의 쿠키를 볼 수 없고, 사이트 C는 사이트 A의 쿠키를 볼 수 없으며, 사이트 B는 둘 다 볼 수 없습니다.

정규 발급자를 두면 익명 로그인 시도마다 하나의 예측 가능한 경로를 제공할 수 있습니다.

Site B
  → authentication issuer
  → issuer session exists?
      yes → create one-time handoff
      no  → show normal login
  → Site B consumes handoff
  → Site B creates local session

발급자가 애플리케이션의 나머지 부분까지 서비스할 필요는 없습니다. 여기서 중요한 책임은 재사용 가능한 인증 세션을 소유하고, 신뢰 사이트에 범위가 엄격히 제한된 핸드오프를 발급하는 것입니다.

소규모 시스템이라면 기존 사이트 하나가 이 역할을 맡을 수 있습니다. 더 큰 아키텍처라면 전용 인증 오리진을 두는 것이 신뢰 모델을 이해하기 더 쉽게 만들 수 있습니다.

모든 방문자를 발급자를 거치게 하지 않는다

익명 사용자가 페이지를 볼 때마다 발급자 흐름이 자동으로 실행되어서는 안 됩니다.

기존 세션이 있는지 확인하려는 이유만으로 모든 방문자를 다른 도메인으로 리디렉션하면 불필요한 탐색, 추가 지연 시간, 더 많은 실패 지점, 더 시끄러운 분석 데이터, 더 복잡한 캐시 동작이 생깁니다.

공개된 SEO 민감 페이지에도 적합하지 않습니다. 공개 URL을 요청한 크롤러나 일반 방문자에게는 먼저 인증 우회 경로를 강제하는 대신 해당 공개 페이지를 직접 제공해야 합니다.

제가 사용하는 규칙은 다음과 같습니다.

페이지를 불러올 때 발급자를 확인하러 가지 않는다. 명시적인 Log In 동작이나 의도적인 인증된 사이트 간 탐색이 있을 때만 크로스도메인 인증을 시작한다.

이렇게 하면 공개 트래픽은 단순하게 유지하면서도, 선택된 발급자에 이미 유효한 세션이 있는 사용자에게는 빠른 인증을 제공할 수 있습니다.

반환 경로 검증은 단순한 접두사 검사보다 더 세심해야 한다

핸드오프는 보통 이후 브라우저가 어디에 도착해야 하는지를 기억해야 합니다. 이 값을 클라이언트가 임의로 제어하는 URL로 취급하면 오픈 리디렉션이 될 수 있습니다.

가능하다면 가장 안전한 설계는 완전한 목적지 URL 자체를 받지 않는 것입니다. 알려진 목적지에 짧은 식별자나 서버 측 매핑을 사용하면 필요한 URL 파싱과 검증을 줄일 수 있습니다.

상대 경로를 받아야 한다면 신중하게 검증하고 대상 사이트 내부로 제한해야 합니다.

다음은 의도적으로 축약한 예이며, 완전한 리디렉션 검증 루틴은 아닙니다.

function isSafeReturnPath(value) {
  if (!value.startsWith('/')) return false;
  if (value.startsWith('//')) return false;
  if (value.includes('\\\\')) return false;

  const base = 'https://example.invalid';
  const parsed = new URL(value, base);

  return parsed.origin === base;
}

실제 구현에서는 디코딩 동작, 잘못된 입력, 제어 문자, 정규화, 애플리케이션 라우팅 규칙, 그리고 리디렉션이 만들어지기 전에 프록시나 프레임워크가 수행하는 변환도 고려해야 합니다.

보안 불변 조건은 파서 세부사항보다 단순합니다. 핸드오프는 대상 애플리케이션 안에서 허용된 위치만 선택할 수 있어야 하며, 임의의 외부 목적지를 선택해서는 안 됩니다.

정상 경로보다 실패 사례가 더 중요하다

로그인 성공으로 확인할 수 있는 것은 기본 경로가 동작한다는 사실뿐입니다. 더 유용한 테스트는 신뢰 경계를 실제로 검증합니다.

저는 다음과 같은 사례에서 동작을 확인했습니다.

  • 인증되지 않은 출처가 핸드오프를 발급하려는 경우.
  • CSRF 보호가 없는 경우.
  • Origin이 없거나 불투명하거나 예상과 다른 경우.
  • 신뢰되지 않은 대상.
  • 안전하지 않은 반환 목적지.
  • 잘못된 대상에 제시된 티켓.
  • 잘못된 오리진에서의 소비 시도.
  • 티켓 재사용.
  • 티켓 만료.
  • 사용자가 더 이상 존재하지 않는 경우.
  • 예상한 POST 본문이 아니라 URL을 통해 티켓이 제공된 경우.

특히 직접 테스트할 가치가 있는 불변 조건은 두 가지입니다. 거부된 요청이 정상 흐름에서는 여전히 유효한 티켓을 실수로 폐기해서는 안 되며, 성공적으로 소비된 티켓은 즉시 다시 사용할 수 없게 되어야 합니다.

프로토콜은 사이트별 쌍대 연동 없이 확장된다

사이트가 늘어나도 핸드오프 설계의 본질은 바뀌지 않습니다.

참여하는 각 사이트에 필요한 것은 다음과 같은 작은 공통 계약입니다.

  • 안정적인 논리 식별자.
  • 신뢰할 수 있는 공개 오리진.
  • 공유 계정 신원에 대한 접근 권한.
  • 인증된 출처용 핸드오프 발급 작업.
  • 핸드오프 소비 작업.
  • 신뢰 사이트 레지스트리.
  • 호스트에 바인딩된 로컬 세션.
  • 안전한 반환 목적지 정책.
  • 익명 사용자의 Log In 흐름에서 사용할 알려진 발급자.

확장할 때 중요한 선택은 사이트 조합별로 별도의 인증 로직을 만들지 않는 것입니다. 이미 인증된 사이트는 다른 신뢰 사이트에 같은 종류의 핸드오프를 발급할 수 있습니다. 익명 사이트는 브라우저를 알려진 발급자로 보낼 수 있습니다.

이렇게 하면 늘어나는 특수 사례의 조합이 아니라 하나의 프로토콜을 기반으로 시스템을 유지할 수 있습니다.

로그인 이식성과 로그아웃 전파는 별개의 문제다

서로 독립적인 호스트 바인딩 세션은 또 하나의 유용한 구분을 만들어냅니다. 로그인을 다른 사이트에서도 이어갈 수 있게 만든다고 해서 전역 로그아웃의 의미까지 자동으로 정해지는 것은 아닙니다.

사용자가 사이트 A에서 로그아웃하더라도 백엔드가 의도적으로 폐기하지 않는 한 다른 사이트의 로컬 세션은 계속 유효할 수 있습니다. 이는 세션 관리 정책의 문제이지 SSO 핸드오프의 실패가 아닙니다.

시스템은 로컬 로그아웃, 전역 로그아웃, 명시적 세션 관리 중 하나를 선택할 수 있습니다. 이 결정을 핸드오프와 분리하면 두 메커니즘 모두 이해하기 쉬워집니다.

제가 사용하는 모델

이 문제를 “도메인 간 로그인 쿠키 공유”라고 설명하는 것을 그만두자 아키텍처가 더 단순해졌습니다.

글로벌 사용자 신원은 하나이고, 각 도메인은 자기 브라우저 세션을 소유하며, 수명이 짧은 일회성 핸드오프를 통해 인증된 신뢰 오리진이 또 다른 로컬 세션 생성을 승인할 수 있다.

익명 상태의 사이트에 직접 들어가는 경우에는 한 가지 규칙이 더 중요합니다. 브라우저를 사용자의 기존 세션을 실제로 볼 수 있는 발급자로 보내야 합니다. 사용자가 이전에 그 발급자에서 로그인했다면 왕복 과정은 거의 눈에 띄지 않을 수 있습니다. 발급자에게 유효한 세션이 없다면, 관련 없는 다른 도메인의 세션이 보이는 것처럼 처리하지 말고 일반 인증으로 폴백해야 합니다.

이 모델은 브라우저의 도메인 경계를 유지하고 공개 방문자에게 불필요한 인증 리디렉션을 피하면서도 의도한 경험을 제공합니다. 즉, 발급자에서 로그인하고 나중에 다른 신뢰 사이트를 연 뒤 Log In을 클릭하면, 비밀번호를 다시 입력하지 않고 새 로컬 세션을 만들어 돌아올 수 있습니다.