Pingerchips LogoPingerchips

Authentication

One capability-token scheme for every Pingerchips product — channels, Durable Objects, and chat.

Every Pingerchips request authenticates one of two ways:

CallerMechanism
Your backend → Pingerchips (server-to-server)X-App-Key + X-App-Secret headers
A browser client → the WebSocketa 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_xxx

No 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

FormMinted byRound-tripLifetimejti
fast-path {appKey}.{payloadB64}.{sigB64}authenticate, authenticateObject, authorize (server SDK)none — pure function of (appSecret, socketId, capabilities)no expiryno
JWT (aud: "pingerchips:v3", HS256)POST /api/auth (authenticateChat, issueToken)one callexp (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}
PartValue
appKeyyour App Key, verbatim
payloadB64base64url(JSON({ socket_id, capabilities, client_id? }))
sigB64base64url(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
productverbsresource
channelsubscribe, publish, presencea channel name
objectread{type}/{key}
chatsubscribe, publish:user_message, cancel_own, tool_approvala thread id

The resource is one of:

FormMatches
ordersexactly 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

CapabilityGrants
channel:subscribe:private-user-42join private-user-42
channel:presence:presence-lobbyappear in the presence-lobby member list
channel:publish:room-*client-publish to any room-… channel
object:read:order/order-42subscribe to that one Durable Object
object:read:order/*subscribe to any order object
chat:subscribe:thread-abcjoin thread thread-abc
chat:publish:user_message:thread-abcsend 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-lobby

One Durable Object

pc.authenticateObject(socketId, 'order', 'order-42');
// → { auth }   caps: object:read:order/order-42

Many 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 / resourceToken needed?
Public channel, app auth disabledno
Public channel, app auth enabled (enable_user_authentication = 1)yes — channel:subscribe:{name}
private-* / presence-* channelalways
Durable Object (browser)always — object:read:{type}/{key}
Chat thread (client)when the app has auth enabled

Next steps

On this page