Chat Agent Authentication: Identifying Users and Maintaining Secure Sessions
Implement secure authentication for chat agents with JWT tokens, anonymous-to-authenticated session linking, user identification strategies, and session security best practices for production deployments.
Why Chat Authentication is Different
Web application authentication is well-understood: the user logs in, gets a session cookie, and every subsequent request includes that cookie. Chat agent authentication is more nuanced because you need to handle three distinct user states simultaneously. Anonymous visitors who have never identified themselves need to chat without friction. Known users who are logged into your application need their chat agent to know who they are and access their account data. And transitioning users who start anonymous but later provide their email or log in need their conversation history preserved across that transition.
Getting this wrong creates jarring experiences. A user chats for five minutes about their order, logs in, and the agent forgets everything. Or worse, an unauthenticated user sees another user's data because session isolation failed.
Session Architecture
Design your chat sessions with a layered identity model:
from dataclasses import dataclass, field
from datetime import datetime, timedelta
import uuid
import jwt
SECRET_KEY = "your-secret-key" # Use env var in production
@dataclass
class ChatSession:
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
anonymous_id: str = field(default_factory=lambda: str(uuid.uuid4()))
user_id: str | None = None
created_at: datetime = field(default_factory=datetime.utcnow)
last_active: datetime = field(default_factory=datetime.utcnow)
metadata: dict = field(default_factory=dict)
@property
def is_authenticated(self) -> bool:
return self.user_id is not None
def link_user(self, user_id: str):
self.user_id = user_id
self.last_active = datetime.utcnow()
def create_session_token(session: ChatSession) -> str:
payload = {
"session_id": session.session_id,
"anonymous_id": session.anonymous_id,
"user_id": session.user_id,
"exp": datetime.utcnow() + timedelta(hours=24),
"iat": datetime.utcnow(),
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
def verify_session_token(token: str) -> dict:
try:
return jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise ValueError("Session expired")
except jwt.InvalidTokenError:
raise ValueError("Invalid session token")
Every visitor gets an anonymous session immediately. No login wall, no friction. When the user authenticates through your main application, the chat session links to their user ID while preserving the conversation history.
WebSocket Authentication
Authenticate the WebSocket connection during the handshake, not after:
from fastapi import WebSocket, WebSocketDisconnect, Query, status
@app.websocket("/ws/chat")
async def authenticated_chat(
websocket: WebSocket,
token: str = Query(None),
):
# Verify token during handshake
session = None
if token:
try:
payload = verify_session_token(token)
session = await load_session(payload["session_id"])
except ValueError:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
if not session:
session = ChatSession()
await save_session(session)
await websocket.accept()
token = create_session_token(session)
await websocket.send_json({"type": "session", "token": token})
try:
while True:
data = await websocket.receive_json()
if data.get("type") == "authenticate":
user = await verify_user_credentials(data)
if user:
session.link_user(user["id"])
await save_session(session)
new_token = create_session_token(session)
await websocket.send_json({
"type": "authenticated",
"token": new_token,
"user": {"name": user["name"]},
})
continue
# Process chat message with session context
response = await process_message(data, session)
await websocket.send_json(response)
except WebSocketDisconnect:
session.last_active = datetime.utcnow()
await save_session(session)
Frontend Token Management
The TypeScript client handles token storage and automatic reconnection with authentication:
class AuthenticatedChatClient {
private ws: WebSocket | null = null;
private token: string | null = null;
private readonly storageKey = "chat_session_token";
constructor(private endpoint: string) {
this.token = localStorage.getItem(this.storageKey);
}
connect() {
const url = this.token
? `${this.endpoint}?token=${this.token}`
: this.endpoint;
this.ws = new WebSocket(url);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "session" || data.type === "authenticated") {
this.token = data.token;
localStorage.setItem(this.storageKey, data.token);
}
};
}
authenticate(email: string, password: string) {
this.ws?.send(JSON.stringify({
type: "authenticate",
email,
password,
}));
}
linkFromMainApp(userJwt: string) {
// When the user logs into the main app, link the chat session
this.ws?.send(JSON.stringify({
type: "authenticate",
method: "jwt",
token: userJwt,
}));
}
send(content: string) {
this.ws?.send(JSON.stringify({ type: "message", content }));
}
}
Session Security Checklist
Several security concerns are specific to chat sessions:
See AI Voice Agents Handle Real Calls
Book a free demo or calculate how much you can save with AI voice automation.
Token rotation — Issue a new token after authentication state changes. When an anonymous user logs in, the old anonymous token should no longer work. This prevents session fixation attacks.
Rate limiting — Apply per-session rate limits on message sending. A reasonable default is 20 messages per minute. This prevents abuse and protects your LLM budget.
Data isolation — When the agent calls tools that access user data, always filter by the authenticated user ID from the session, never from the message content. A user saying "show me orders for user123" should only see their own orders.
@function_tool
async def get_user_orders(ctx: RunContextWrapper[ChatSession]) -> str:
"""Retrieve the current user's recent orders."""
session = ctx.context
if not session.is_authenticated:
return "Please log in to view your orders."
# ALWAYS use session.user_id, never user-supplied IDs
orders = await db.fetch_orders(user_id=session.user_id, limit=5)
return format_orders(orders)
Session expiration — Expire inactive sessions after a reasonable period (24 hours for anonymous, 7 days for authenticated). Clean up expired sessions and their message history according to your data retention policy.
FAQ
How do I handle authentication when embedding the chat widget on a third-party site?
Use a signed initialization token. Your backend generates a short-lived token (5 minutes) containing the partner site's ID and optional user context. The partner site includes this token when initializing the widget. Your WebSocket endpoint verifies the token signature and partner ID before accepting the connection. Never trust user data from the embedding page without server-side verification.
Should I store chat session tokens in cookies or localStorage?
For same-origin chat widgets, use httpOnly cookies — they are automatically included in WebSocket handshakes and are immune to XSS theft. For cross-origin embedded widgets where cookies are blocked by browser policies, use localStorage with a short expiration. Always pair localStorage tokens with CSRF protection and validate the origin header on your WebSocket endpoint.
How do I merge conversation history when an anonymous session links to an existing user?
Check if the user already has previous chat sessions. If they do, keep both histories but mark the newly linked session as the active one. On the frontend, optionally show a "Continue previous conversation?" prompt. Never silently merge without user consent, as the anonymous conversation may contain information the user does not want associated with their account.
#Authentication #JWT #SessionManagement #Security #ChatAgent #AgenticAI #LearnAI #AIEngineering
CallSphere Team
Expert insights on AI voice agents and customer communication automation.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.