비로그인 사용자 (게스트)

로그인하지 않은 방문자도 채팅방에서 말할 수 있게 하는 법. 커뮤니티 오픈채팅, 라이브 방송 채팅, 고객 상담 위젯처럼 가입 없이 바로 대화가 중요한 서비스에 쓴다.

이 문서의 요점은 하나다: 게스트의 신원은 id가 아니라 서명된 자격증명으로 들고 다닌다. 채팅 엔진은 당신 백엔드가 서명한 토큰을 믿을 뿐이라, 누가 누구인지 판단하는 것은 당신 백엔드다. @kispi/chat/server의 chat.guest()가 그 판단을 안전하게 하는 도구다.

흔한 실수 — 브라우저가 준 id에 서명하기

ts
// ❌ 이렇게 하지 않는다
export const POST = async ({ request }) => {
  const { guestId } = await request.json()          // 브라우저가 localStorage에서 꺼내 보낸 값
  const userId = isValidGuestId(guestId) ? guestId : `g_${crypto.randomUUID()}`
  return json({ userId, token: chat.token({ userId, name: '손님' }) })
}

게스트 id는 비밀이 아니다. 모든 메시지에 sender.id로 실려 방의 모든 사람에게 간다. 그러니 위 엔드포인트는 남의 메시지에서 id를 복사해 보낸 사람에게 그 게스트의 토큰을 서명해 준다 — 사칭, 남의 메시지 수정·삭제, 밴 회피가 전부 열린다. g_ + UUID 같은 형식 검사는 이것을 막지 못한다.

안전한 방법 — chat.guest()

브라우저에 id 대신 자격증명을 들려 보낸다. 자격증명은 g1.<userId>.<서명> 모양이고 서명은 당신 백엔드만 아는 guestSecret으로 만든다. 복사한 id로는 만들 수 없다.

1. 비밀 하나를 만든다

bash
openssl rand -base64 48   # → CHAT_GUEST_SECRET (32자 이상)

백엔드 환경변수에만 둔다. sk_와 다른 값이어야 한다 — sk_를 교체할 때마다 모든 게스트가 새 사람이 되면 안 되기 때문이다.

2. 토큰 엔드포인트에서 로그인 유저와 게스트를 가른다

ts
import { createChatServer } from '@kispi/chat/server'

const chat = createChatServer({
  secretKey: env.CHAT_SECRET_KEY,
  guestSecret: env.CHAT_GUEST_SECRET,
})

// SvelteKit 예시. Express·Nuxt(Nitro)·Next도 모양은 같다.
export const POST = async ({ locals, cookies }) => {
  const user = locals.user
  if (user) {
    return json({ token: chat.token({ userId: String(user.id), name: user.nickname }) })
  }

  const guest = chat.guest({
    credential: cookies.get('chat_guest'),   // 없거나 위조면 새 게스트가 만들어진다
    name: '손님',
  })
  // httpOnly: 페이지 스크립트(XSS)가 읽지 못하게
  cookies.set('chat_guest', guest.credential, {
    path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 400,
  })
  return json({ token: guest.token })
}
python
import base64, hmac, hashlib, os, secrets

SECRET = os.environ["CHAT_GUEST_SECRET"].encode()

def b64u(b):
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

def mac(guest_id):
    return b64u(hmac.new(SECRET, guest_id.encode(), hashlib.sha256).digest())

def verified_id(cookie):
    parts = (cookie or "").split(".")
    if len(parts) != 3 or parts[0] != "g1":
        return None
    # compare_digest: 서명을 앞자리부터 맞혀 나가는 시간차 공격을 막는다
    return parts[1] if hmac.compare_digest(mac(parts[1]), parts[2]) else None

# 쿠키가 서명을 통과하면 그 게스트를 잇고, 아니면 새로 만든다
def guest(cookie):
    guest_id = verified_id(cookie)
    created = guest_id is None
    if created:
        guest_id = "g_" + b64u(secrets.token_bytes(16))  # 22자
    # 쿠키는 httpOnly secure로 내려보내고, 토큰은 /docs/server의 chat_token()으로 서명한다
    return guest_id, f"g1.{guest_id}.{mac(guest_id)}", created

g1.<id>.<서명>이라는 형식은 이 백엔드가 정한 것이고 엔진과의 계약이 아니다. 엔진은 서명된 토큰의 유저 id만 본다.

guest()는 { userId, credential, token, created }를 돌려준다. 네트워크도 DB도 쓰지 않는다. created가 true면 이번에 처음 만든 게스트다(환영 문구를 띄우는 데 쓸 수 있다).

3. 브라우저는 아무것도 보관하지 않는다

클라이언트 코드는 로그인 유저일 때와 같다. 쿠키는 브라우저가 알아서 실어 보낸다.

ts
const chat = createChatClient({
  key: PUBLIC_CHAT_PK,
  token: async () => (await fetch('/api/chat-token', { method: 'POST' }).then(r => r.json())).token,
})

localStorage에 게스트 id를 넣던 코드는 지운다.

다른 언어에서

위 Python 탭이 그 전부다. 세션 테이블이든 서명된 쿠키든 당신 방식대로 해도 되고, 지킬 것은 둘이다 — 브라우저가 돌려준 userId를 그대로 서명하지 않는다, 그리고 이 엔드포인트에 IP당 호출 제한을 건다.

지켜야 할 것

id 공간을 나눈다 게스트 id는 g_로 시작한다. 로그인 유저의 id가 g_로 시작하지 않게 하면 두 공간이 겹치지 않는다
호출 제한 게스트 발급은 인증 없이 열린 문이다. 토큰 엔드포인트에 IP당 호출 제한을 건다. 엔진은 누가 게스트를 몇 명 만드는지 모른다
비밀 교체 guestSecret: [새것, 옛것]처럼 배열로 준다. 새것으로 서명하고 전부로 검증하며, 옛것으로 검증된 자격증명은 같은 id로 새로 서명해 돌려준다. 방문이 한 바퀴 돌고 나면 옛것을 뺀다
밴 게스트 밴은 그 userId에 걸린다. 쿠키를 지우면 새 게스트가 되므로, 게스트 남용이 문제라면 IP 기반 제한이나 "게스트는 읽기만" 정책을 함께 쓴다

CDN이나 프록시 뒤라면

토큰 엔드포인트(/api/chat-token 같은 것) 앞에 CloudFront·Cloudflare·nginx가 있으면 세 가지를 확인한다. 셋 다 엔진이 아니라 당신 인프라의 설정이고, 틀리면 에러 없이 조용히 틀린다.

증상 할 일
쿠키 전달 새로고침할 때마다 새 게스트가 된다 CDN이 오리진에 쿠키를 넘기는지 본다. CloudFront는 기본 설정이 쿠키를 넘기지 않는다 — 토큰 경로에만 쿠키를 전달하는 캐시·오리진 요청 정책을 붙인다
캐시 끄기 서로 다른 방문자가 같은 게스트가 된다 토큰 응답은 사람마다 다르다. 토큰 경로는 캐시하지 않고, 응답에 Cache-Control: no-store를 둔다
방문자 IP IP당 제한이 CDN 엣지 몇 대에 걸려 모두를 막거나 아무도 못 막는다 요청의 IP는 방문자가 아니라 바로 앞 프록시다. 당신의 프록시가 넣은 헤더만 믿는다(CloudFront는 CloudFront-Viewer-Address, nginx는 real_ip_header + set_real_ip_from으로 신뢰할 대역을 좁힌다)

방문자 IP 헤더는 오리진에 직접 붙으면 누구나 위조할 수 있다. 오리진을 CDN에서만 받게 막거나 (보안 그룹을 CDN 대역으로, 또는 CDN이 붙이는 비밀 헤더를 오리진이 검사), 그럴 수 없다면 그 헤더를 보지 않고 직접 받은 IP로 제한한다.

브라우저가 엔진에 붙는 경로(api.chat.gravex.app)는 이와 무관하다 — 거기에는 프록시를 둘 일이 없다.