Guest users (not logged in)

How to let visitors who aren't logged in talk in chat rooms. Use this for services where jumping into a conversation without signing up matters: community open chats, live-stream chat, customer support widgets.

This page comes down to one point: a guest's identity travels as a signed credential, not an id. The chat engine simply trusts tokens your backend signs, so deciding who is who is your backend's job. chat.guest() from @kispi/chat/server is the tool for making that decision safely.

A common mistake — signing an id the browser sends

ts
// ❌ Don't do this
export const POST = async ({ request }) => {
  const { guestId } = await request.json()          // value the browser pulled from localStorage and sent
  const userId = isValidGuestId(guestId) ? guestId : `g_${crypto.randomUUID()}`
  return json({ userId, token: chat.token({ userId, name: 'Guest' }) })
}

A guest id isn't a secret. It's attached to every message as sender.id and delivered to everyone in the room. So the endpoint above signs that guest's token for anyone who copies the id from their messages — impersonation, editing and deleting their messages, and evading bans all open up. Format checks like g_ + UUID don't prevent this.

The safe way — chat.guest()

Send the browser a credential instead of an id. A credential looks like g1.<userId>.<signature>, and the signature is made with a guestSecret only your backend knows. It can't be forged from a copied id.

1. Create a secret

bash
openssl rand -base64 48   # → CHAT_GUEST_SECRET (at least 32 characters)

Keep it only in your backend's environment variables. It must be different from your sk_ — every rotation of the sk_ shouldn't turn all your guests into new people.

2. Split logged-in users and guests in your token endpoint

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

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

// SvelteKit example. Express, Nuxt (Nitro), and Next look the same.
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'),   // if missing or forged, a new guest is created
    name: 'Guest',
  })
  // httpOnly: so page scripts (XSS) can't read it
  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: no timing side channel to guess the signature byte by byte
    return parts[1] if hmac.compare_digest(mac(parts[1]), parts[2]) else None

# Resume the guest if the cookie verifies, otherwise mint a new one
def guest(cookie):
    guest_id = verified_id(cookie)
    created = guest_id is None
    if created:
        guest_id = "g_" + b64u(secrets.token_bytes(16))  # 22 chars
    # Send the cookie back httpOnly and secure; sign the token with chat_token() from /en/docs/server
    return guest_id, f"g1.{guest_id}.{mac(guest_id)}", created

That g1.<id>.<signature> shape is this backend's own choice, not a contract with the engine. The engine only looks at the user id inside a signed token.

guest() returns { userId, credential, token, created }. It uses no network and no database. If created is true, the guest was just created (you can use it to show a welcome message).

3. The browser stores nothing

The client code is the same as for logged-in users. The browser sends the cookie on its own.

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

Delete any code that stored the guest id in localStorage.

In another language

The Python tab above is all of it. A session table or a signed cookie of your own is fine, as long as two things hold: never sign a userId the browser sent back, and rate-limit this endpoint per IP.

Rules to follow

Split the id space Guest ids start with g_. As long as your logged-in users' ids never start with g_, the two spaces won't overlap
Rate limiting Issuing guests is a door that's open without authentication. Rate-limit your token endpoint per IP. The engine has no idea who is creating how many guests
Secret rotation Pass an array like guestSecret: [new, old]. It signs with the new one and verifies with all of them, and credentials verified with the old one are re-signed with the same id and returned. Once your visitors have cycled through, remove the old one
Bans A guest ban applies to that userId. Clearing cookies makes a new guest, so if guest abuse is a problem, combine it with IP-based limits or a "guests can only read" policy

Behind a CDN or proxy

If CloudFront, Cloudflare or nginx sits in front of your token endpoint (something like /api/chat-token), check three things. All three are your infrastructure's settings, not the engine's, and getting them wrong fails silently.

Symptom What to do
Forward cookies Every reload is a new guest Check that the CDN forwards cookies to the origin. CloudFront does not by default — attach a cache/origin-request policy that forwards cookies on the token path only
Do not cache Different visitors become the same guest Token responses are per person. Do not cache the token path, and send Cache-Control: no-store
Visitor IP A per-IP limit lands on a handful of edge servers, blocking everyone or no one The request IP is the proxy in front of you, not the visitor. Trust only a header your own proxy sets (CloudFront-Viewer-Address on CloudFront; on nginx, real_ip_header plus set_real_ip_from narrowed to the ranges you trust)

Anyone who reaches the origin directly can forge the visitor-IP header. Lock the origin to the CDN (a security group limited to CDN ranges, or a secret header the CDN adds and the origin checks); if you cannot, ignore the header and limit on the IP you actually received.

The browser's connection to the engine (api.chat.gravex.app) is unaffected — there is no proxy of yours on that path.