Build multi-modal AI applications using our new open-source Vision AI SDK.

How Stream Chat Authentication Works: User Tokens, Refresh, and Secret Handling

New
11 min read

The best authentication system is the one your users never notice.

Raymond F
Raymond F
Published July 8, 2026
Stream Chat Authentication: secure, short-lived tokens keep your chat safe

TL;DR

  • Stream Chat authentication works by having your backend sign short-lived JWTs with your API secret, which is handed to the client at login and used to open a WebSocket connection to Stream.
  • Four credentials are in play: the API key (public, safe for the client), the API secret (server-only, never shipped to the client), the Stream user token (signed by your backend, presented to Stream), and your session token (authenticates the client to your own backend during token refresh).
  • Pass a tokenProvider function - not a string - to connectUser so the SDK can fetch a fresh token automatically when the current one expires.
  • Always include the iat claim when minting tokens; without it, per-user token revocation won't work.

API secrets are the last thing you want to leak. With one, someone else can access your data, burn through your tokens, run up your bill, and act as you anywhere that secret is trusted.

For a lot of services, this isn't too much of a problem as the work is happening on the backend, on your server, away from your users.

Not so with chat. Chat has to happen in the client, exactly where you don't want a secret. Any secret in the client is shipped to every single browser and every single user. So how do you keep a secret when you also have to share access with everyone who wants to chat?

This guide is about exactly that: keeping your secret on the server while every client still gets the access it needs, using short-lived tokens your backend signs, and the SDK refreshes on its own.

Why a Chat SDK Can't Authenticate Itself

Every connection and API request that the SDK makes to Stream must be authenticated. That authentication uses tokens signed with your API secret, and the secret can't be stored on the client. So the signing has to happen somewhere that can safely hold it.

Three components sit on different sides of a trust boundary:

  • Your client holds the public API key and a signed token, and nothing that could authorize a different user.
  • Your backend holds the secret and is the only party that signs tokens.
  • Stream validates those tokens, hosts the channels, and routes messages.

When a user logs in, it uses the secret to sign a short-lived token scoped to that single user, then hands the token to the client. The client presents that token to Stream when it connects, proving who the user is. The secret never leaves your server.

Authentication flow: client receives token from backend at login, then connects directly to Stream via WebSocket

At login, the client talks to your backend over HTTPS and receives a token; after that, your backend drops out of the path. The client calls connectUser, which opens a persistent WebSocket directly to Stream's edge, using the token to authenticate the handshake.

From then on, the client talks directly to Stream, and your backend is out of the data path. SDK calls, such as sending messages and querying channels, go over Stream's REST API, while the WebSocket carries real-time events: new messages, typing, read state, and presence. Your backend only re-enters when the token expires, and the client needs a fresh one, or when the user signs out. That keeps backend load flat regardless of chat volume.

Tokens, Secrets, and Where Each One Lives

Four credentials are in play:

CredentialWhere it livesWhat it does
API keyClientPublic identifier - tells Stream which app a request belongs to. Safe to ship in your client bundle.
API secretBackend onlySigns Stream user tokens. Anyone who has it can mint a valid token for any user. Never leaves your server.
Stream user tokenClient (after login)Authenticates the client to Stream. A short-lived JWT signed by your backend, scoped to a single user.
Session tokenClient (after login)Authenticates the client to your own backend. Used to authorize token refresh requests when the Stream token expires.

What a Stream User Token Actually Is

A Stream user token is a JWT. No proprietary format, no special handshake. If you've worked with auth before, you already know most of what's happening (if not, here's a primer on JSON Web Tokens).

The token has two claims that matter:

  • user_id identifies who the token authenticates. It has to match the ID you pass to connectUser, which creates the user if it doesn't exist yet.
  • exp is the expiration, a Unix timestamp in seconds. Once that time passes, Stream rejects the token, and the SDK needs a fresh one.

The signature is HMAC-SHA256 over the header and payload, keyed with your Stream API secret. Stream verifies the signature on every connection by recomputing it with the secret on their side. If it matches, they trust the user_id claim. If it doesn't, the connection fails.

You can decode any Stream token at jwt.io to see what it contains. You cannot forge one without the secret.

The API Key is Public, the Secret is Not

The Stream dashboard hands you two credentials: an API key and an API secret. The naming makes them sound interchangeable. They aren't.

The API key is a public identifier. It tells Stream which application a request belongs to. It's safe to ship in your client bundle, log in console output, or include in network requests from the browser.

The API secret is the signing key for user tokens. It belongs on your backend and nowhere else. Anyone who has it can mint a valid token for any user in your application, so the whole security model depends on it never leaving your servers.

The secret should appear in exactly one place: the call that creates your server-side Stream client.

ts
1
2
3
4
5
const serverClient = new StreamChat( config.streamApiKey, config.streamApiSecret, { disableCache: true } );

The disableCache option is recommended for server-side use. It disables the in-memory cache that only benefits client-side connections. The client side never sees your secret. It only ever gets the public API key. If you find yourself referencing STREAM_API_SECRET from anything client-side, something has gone wrong. The same goes for environment variables prefixed with VITE_, NEXT_PUBLIC_, REACT_APP_, or anything else your bundler exposes to the browser. Bundler-exposed env vars should never touch the secret.

The Session Token

The session token is the one credential here that has nothing to do with Stream. It authenticates the client to your own backend instead. It matters during refresh: when the Stream token expires, the client uses the session token to prove it's authorized to request a fresh one from your backend. The refresh section covers that flow.

Building your own app? Get access to our Livestream or Video Calling API and launch in days!

Minting a Token at Login

Login is where the secret does real work for the first time. The user submits credentials; your backend checks them against your user store, and if they're valid, you mint a token.

You can also upsert the user into Stream here, though it's optional. connectUser creates the user automatically when the client connects, so this isn't a prerequisite. Doing it server-side is worthwhile when your backend is the source of truth for profile data:

ts
1
2
3
4
5
await serverClient.upsertUser({ id: user.id, name: user.name, image: user.image, });

upsertUser is idempotent, so calling it on every login is fine. If the user changes their display name in your app, the next login applies that change.

Minting the token itself is one line, wrapped here to compute the timestamps:

ts
1
2
3
4
5
6
function generateStreamToken(userId: string) { const issuedAt = Math.floor(Date.now() / 1000); const expiresAt = issuedAt + config.streamTokenExpirationSeconds; const token = serverClient.createToken(userId, expiresAt, issuedAt); return { token, expiresAt }; }

One thing that catches people: the second argument to createToken is a Unix timestamp in seconds, not a duration. If you pass 3600 expecting "one hour from now," you'll get a token whose exp claim is 3600 seconds after the Unix epoch, which expired in 1970. Compute the absolute timestamp first, then pass it.
The third argument, issuedAt, is optional, but you should include it. Per-user token revocation only works for tokens that carry an iat claim, so this one line lets you invalidate a specific user's tokens later.

The login response sends back the Stream token, its expiration timestamp, the public API key, and the session token. The secret itself never goes in the response.

ts
1
2
3
4
5
6
7
res.json({ user: toPublicUser(user), sessionToken, // For authenticating to your backend streamToken, // For authenticating to Stream streamTokenExpiresAt, // Unix timestamp, lets the client show a countdown apiKey: config.streamApiKey, });

Connecting the Client and Keeping It Connected

The client connects with chatClient.connectUser(user, token). The second argument can be a string or an async function returning a string. This choice is the most consequential one in the whole integration.

If you pass a string, the SDK retains that token and has no way to obtain another. An already-open connection can outlive the token's expiry, but the moment it drops, or the SDK needs a fresh token for a reconnect or an API call, it has nothing to fall back on, and you're reconnecting by hand. Most developers reach for this first because the type signature accepts it, and it works in development, where you never wait long enough to see a token expire.

If you pass a function, the SDK treats it as a token provider. It calls the function once on connect to get the initial token, then again whenever the current token's exp passes. The same function handles the initial connection and indefinite refresh, and the SDK handles the reconnection plumbing internally.

What the tokenProvider Actually Does

A token provider looks like this:

ts
1
2
3
4
5
6
7
8
9
10
const tokenProvider = useCallback(async (): Promise<string> => { console.log("[tokenProvider] Stream token expired, refreshing..."); const data = await authApi.refreshStreamToken(); setState((s) => ({ ...s, streamToken: data.streamToken, streamTokenExpiresAt: data.streamTokenExpiresAt, })); return data.streamToken; }, []);

It hits the backend, obtains a new token, updates the local state so the UI can display the new expiration, and returns the token string. The SDK doesn't care about any state management; it just needs the string.

Why the Refresh Endpoint Needs its Own Auth

The provider above would call an endpoint like POST /auth/token-refresh. Someone has to be allowed to call that endpoint, and that someone has to be the actual logged-in user, not just any client that knows the URL.

The Stream token itself can't authenticate the refresh request. The whole reason we're refreshing is that the token expired. You need a separate credential that outlives the Stream token: a session cookie, your own JWT, OAuth tokens from a provider, whatever your app already uses for general user auth.

Here's that as a session JWT, signed with a different secret than the Stream one:

ts
1
2
3
4
5
function generateSessionToken(userId: string, username: string): string { return jwt.sign({ userId, username }, config.sessionSecret, { expiresIn: "24h", }); }

The refresh route is gated by middleware that validates it:

ts
1
2
3
4
5
router.post("/token-refresh", requireAuth, async (req, res) => { const { userId } = req.session!; const { token, expiresAt } = generateStreamToken(userId); res.json({ streamToken: token, streamTokenExpiresAt: expiresAt }); });

config.sessionSecret and config.streamApiSecret are different values. Don't share them. The session secret protects your backend's auth. The Stream secret signs tokens that authenticate against Stream's servers. Mixing them means a compromise of one becomes a compromise of both.

The full sequence would be this:

StepWhereWhat happens
1Stream SDKDetects if the current JWT exp has passed
2Stream SDKCalls the tokenProvider function
3ClientSends POST /auth/token-refresh with the session token in the Authorization header
4BackendrequireAuth middleware validates the session JWT
5BackendSigns a fresh token with generateStreamToken(userId)
6BackendResponds with the new token and new expiresAt
7ClienttokenProvider returns the new token string to the SDK
8Stream SDKReconnects with the new token, resumes the WebSocket

On a successful refresh, the user sees nothing: messages don't drop, channel state doesn't reset, and the chat keeps working. Refresh can fail, though, due to a network blip or an expired session, so treat it as a real error path and route the user back to login or to a recoverable state.

What Good Chat Auth Looks Like From the User's Seat

What good chat auth looks like to the user is nothing. They sign in once, and the chat works. Tomorrow they come back, and it still works. If you rotate your secret or revoke their session, they get cleanly bounced to the login page, without a stuck loading state or a half-broken UI.

This is what the built-in patterns in the Stream SDK allow. The token, the provider function, the refresh endpoint, the session credential, the upsert, the cleanup, and the secret that never leaves your server. None of it should be visible from where the user is sitting.

When it's wired correctly, the only sign that auth exists at all is that the user is who they say they are, and the messages they send arrive labeled with their name.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.