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/serveris the tool for making that decision safely.
A common mistake — signing an id the browser sends
// ❌ 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
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
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 })
}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)}", createdThat 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.
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.