Authentication
One capability-token scheme for every Pingerchips product — channels, Durable Objects, and chat.
Every Pingerchips request authenticates one of two ways:
| Caller | Mechanism |
|---|---|
| Your backend → Pingerchips (server-to-server) | X-App-Key + X-App-Secret headers |
| A browser client → the WebSocket | a capability token in the join params |
There is one token format and one capability grammar across channels, Durable Objects, and chat.
The App Secret is a server-only credential. It never appears in browser code, a URL, or a token payload. Your backend holds it; the browser only ever sees a scoped capability token minted from it.
Server-to-server: header auth
Every HTTP call your backend makes — trigger, push, channel history, Durable
Objects, the chat REST API, and the token issuer — carries:
X-App-Key: pk_live_xxx
X-App-Secret: sk_live_xxxNo credentials in the request body. No request signing. The pingerchips-js-server
SDK sets these headers for you; implement them by hand only when calling from a
language without an SDK.
The :app_id path segment on object / app-scoped routes accepts either your
App Key or your App ID — the server resolves the app from the header and checks
the path segment matches.
const res = await fetch(
'https://queue.pingerchips.com/api/apps/pk_live_xxx/trigger',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-App-Key': process.env.PINGERCHIPS_APP_KEY,
'X-App-Secret': process.env.PINGERCHIPS_APP_SECRET,
},
body: JSON.stringify({ channel: 'lobby', event: 'ping', data: {} }),
},
);Failures return 401 { "errors": [{ "status": "401", "title": "Unauthorized" }] }.
Client: capability tokens
A browser joins a private channel, a Durable Object, or a chat thread by putting a token in the join params:
socket.channel(topic, { auth: '<capability token>' })Your backend mints that token — scoped to exactly what this client should access, and bound to its socket — and returns it to the client SDK. The client SDK forwards it unchanged.
Two token forms
| Form | Minted by | Round-trip | Lifetime | jti |
|---|---|---|---|---|
fast-path {appKey}.{payloadB64}.{sigB64} | authenticate, authenticateObject, authorize (server SDK) | none — pure function of (appSecret, socketId, capabilities) | no expiry | no |
JWT (aud: "pingerchips:v3", HS256) | POST /api/auth (authenticateChat, issueToken) | one call | exp (default 300 s) | yes |
Use the fast path for the common case — private/presence channels and Durable
Objects. Use the JWT when you want a short lifetime, a revocable jti, or
server-side issuance you can audit (chat threads default to this).
Both forms are bound to one socket_id. A token minted for socket A is rejected
on socket B.
Fast-path token layout
{appKey}.{payloadB64}.{sigB64}| Part | Value |
|---|---|
appKey | your App Key, verbatim |
payloadB64 | base64url(JSON({ socket_id, capabilities, client_id? })) |
sigB64 | base64url(HMAC-SHA256(appSecret, "{appKey}.{payloadB64}")) |
import crypto from 'crypto';
function mintFastToken(appKey, appSecret, socketId, capabilities, clientId) {
const payload = { socket_id: socketId, capabilities };
if (clientId != null) payload.client_id = clientId;
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sigB64 = crypto
.createHmac('sha256', appSecret)
.update(`${appKey}.${payloadB64}`)
.digest('base64url');
return `${appKey}.${payloadB64}.${sigB64}`;
}The server SDK's authenticate, authenticateObject, and authorize are thin
wrappers over this. It's also exported directly as mintFastToken.
Capability grammar
A capability is a colon-delimited string:
product:verb:resource| product | verbs | resource |
|---|---|---|
channel | subscribe, publish, presence | a channel name |
object | read | {type}/{key} |
chat | subscribe, publish:user_message, cancel_own, tool_approval | a thread id |
The resource is one of:
| Form | Matches |
|---|---|
orders | exactly orders |
order/* | any resource starting order/ |
* | every resource of that product + verb |
A wildcard is only valid as a whole trailing segment — a*b and order/*/x are
rejected. A capability list must be non-empty and duplicate-free; one malformed
entry invalidates the whole list at issuance.
Examples
| Capability | Grants |
|---|---|
channel:subscribe:private-user-42 | join private-user-42 |
channel:presence:presence-lobby | appear in the presence-lobby member list |
channel:publish:room-* | client-publish to any room-… channel |
object:read:order/order-42 | subscribe to that one Durable Object |
object:read:order/* | subscribe to any order object |
chat:subscribe:thread-abc | join thread thread-abc |
chat:publish:user_message:thread-abc | send user messages in that thread |
Issuing tokens (server SDK)
import PingerchipsServer from 'pingerchips-js-server';
const pc = new PingerchipsServer(
process.env.PINGERCHIPS_APP_KEY,
process.env.PINGERCHIPS_APP_SECRET,
);One private / presence channel
pc.authenticate(socketId, 'private-user-42');
// → { auth: '{appKey}.{payloadB64}.{sigB64}' } caps: channel:subscribe:private-user-42
pc.authenticate(socketId, 'presence-lobby', { user_id: 'u-42', name: 'Ada' });
// → { auth, channel_data: '{"user_id":"u-42",...}' }
// caps: channel:subscribe:presence-lobby, channel:presence:presence-lobbyOne Durable Object
pc.authenticateObject(socketId, 'order', 'order-42');
// → { auth } caps: object:read:order/order-42Many grants, one token
pc.authorize(socketId, {
channels: ['orders', 'shipments'], // channel:subscribe:*
presence: ['lobby'], // + channel:presence:lobby
publish: ['orders'], // channel:publish:orders
objects: [{ type: 'order', key: 'order-42' }],
threads: ['thread-abc'], // full chat verb set
capabilities: ['channel:subscribe:raw'], // appended verbatim
}, clientId);
// → { auth } (fast-path)Server-issued JWT
// full client verb set for the thread
await pc.authenticateChat(socketId, threadId, clientId);
// narrowed — read-only
await pc.authenticateChat(socketId, threadId, clientId, ['subscribe']);
// arbitrary grants, as a JWT instead of a fast token
await pc.issueToken(socketId, clientId, { objects: [{ type: 'run', key: runId }] });Both call POST /api/auth.
POST /api/auth directly
POST /api/auth
X-App-Key: pk_live_xxx
X-App-Secret: sk_live_xxx
Content-Type: application/json
{ "socket_id": "…", "client_id": "…", "capabilities": ["chat:subscribe:thread-abc", …] }→ { "auth": "<jwt>" }. A 422 means a missing field or a malformed capability
string; a 401 means bad credentials.
Your auth endpoint
The client SDK POSTs to the authEndpoint you configure and forwards whatever
{ auth } it gets back. A typical endpoint authorizes the user, then mints a
token scoped to that user:
import express from 'express';
import PingerchipsServer from 'pingerchips-js-server';
const app = express();
app.use(express.json());
const pc = new PingerchipsServer(
process.env.PINGERCHIPS_APP_KEY,
process.env.PINGERCHIPS_APP_SECRET,
);
app.post('/pingerchips/auth', (req, res) => {
const { socket_id, channel_name, auth_info } = req.body;
const user = verifySession(auth_info?.token);
if (!user) return res.status(403).json({ error: 'Unauthorized' });
// authorize the specific channel this client asked for
if (channel_name.startsWith('private-') &&
channel_name !== `private-user-${user.id}`) {
return res.status(403).json({ error: 'Access denied' });
}
const userData = channel_name.startsWith('presence-')
? { user_id: user.id, name: user.name }
: null;
res.json(pc.authenticate(socket_id, channel_name, userData));
});For Durable Objects the client posts { socket_id, object_type, object_key };
call pc.authenticateObject(socket_id, object_type, object_key). For chat it
posts { socket_id, thread_id, client_id }; call
pc.authenticateChat(socket_id, thread_id, client_id).
authenticate throws if the channel name isn't private- / presence-
prefixed, or if a presence call is missing user_id. Public channels need no
token.
Agents
A chat agent (server-side, via @pingerchips/ai or the Python SDK) does not
use a capability token. It joins with its own credentials:
chat.connectAgent(threadId, { secret: process.env.PINGERCHIPS_APP_SECRET, agentId: 'assistant' });The agent_id + App Secret pair grants the agent verb set
(publish:token, publish:run_lifecycle, publish:message, …).
When a token is required
| Channel / resource | Token needed? |
|---|---|
| Public channel, app auth disabled | no |
Public channel, app auth enabled (enable_user_authentication = 1) | yes — channel:subscribe:{name} |
private-* / presence-* channel | always |
| Durable Object (browser) | always — object:read:{type}/{key} |
| Chat thread (client) | when the app has auth enabled |
Next steps
- Channel Types — public, private, presence
- Server SDK — every issuance helper
- Durable Objects HTTP API
- Chat authentication
- App Settings — App Key and App Secret