Authentication and keys
Telltale uses one credential in the browser and a different one on your server. They never cross that boundary.
| Where | Prefix | Kept in | Used for |
|---|---|---|---|
| Browser | ao_pk_ | Page markup, tag manager or client config | Selecting the Site and starting the SDK from the Site's website addresses |
| Server | ao_sk_ | A secret manager or server-only environment | Authenticating server API requests for the Site |
Every credential and token Telltale issues, by prefix (for a secret scanner):
| Prefix | What it is | Secret? |
|---|---|---|
ao_pk_ | Publishable key | No: it identifies the Site and appears in page code |
ao_sk_ | Server key | Yes |
ao_ak_ | Analytics key for your own agent (read-only counts) | Yes |
ao_st_ | Browser session token (from the SDK, sent to your backend) | Treat as one: never log it |
ao_ap_ | One-time approval token for a held action | Yes, short-lived |
tt_agent_ | Agent token from the approved route | Yes, short-lived |
whsec_ | Webhook signing secret | Yes |
The ao_ prefixes are historical names from before the product was called Telltale (Renamed names). They are stable and kept on purpose, so existing keys and installs keep working; agent tokens, added later, carry the Telltale prefix tt_.
A publishable key cannot call the server API. Browsers can use it only from the Site's registered origins, because the Telltale server checks the Origin header a browser sends. That check does not bind non-browser clients, which can send any Origin header, so treat the publishable key as public: it identifies the Site, it is not a secret. Session initialization is therefore rate-limited per client IP address and Site (120 per minute, with IPv6 addresses grouped by /48), under a per-Site ceiling of 6,000 per minute, so one client cannot lock a Site's real visitors out. A server key must never reach a browser, a client bundle (for example a NEXT_PUBLIC_ or runtimeConfig.public value), a tag manager, logs, screenshots or an install prompt.
Send the server key as a bearer credential:
Authorization: Bearer ao_sk_your_server_keyRotate a server key
The dashboard shows each server key once.
- In Settings › Channels, keep "Keep existing keys valid for up to seven days" checked and choose Generate replacement server key. Save the new value in your secret manager straight away.
- Deploy the replacement. Both keys authenticate during the overlap, which lasts seven days unless you revoke sooner.
- Confirm production traffic uses the new key, then choose Revoke retired key next to the retiring key ID.
Unchecking the overlap replaces the key immediately and invalidates every previous key, which is the right choice after a leak. At most three keys can be retiring at once. Key summaries show only identifiers and expiry, never values. The administration route is POST /api/admin/rotate {siteId, grace, expectedKeyId}; expectedKeyId makes a stale screen fail with 409 instead of rotating twice.
Rotate the Telltale server's own keys (self-hosted)
Two secrets live beside the database, each 32 random bytes with owner-only permissions: session-key signs browser session tokens (and protected-action approvals and evaluation exports), and webhook-key encrypts the stored webhook signing secrets. Backups copy both, so rotate them when a backup or the data directory may have leaked.
- Stop the Telltale server.
- In the directory that holds DB_PATH, move
session-keytosession-key.previous(orwebhook-keytowebhook-key.previous, or both). The server writes a new key at the next start. On Postgres the keys are the TELLTALE_SESSION_KEY and TELLTALE_WEBHOOK_KEY variables instead; Server API and recovery explains their_PREVIOUSoverlap. - Start the server. Every workspace's audit log records
key.rotatedonce, with the key's name and fingerprint, never the key.
During the overlap both keys work:
- Session key: tokens signed with the previous key keep verifying for 24 hours after the rotation (the rotation time is the
.previousfile's change time), long enough for every session issued before it to expire. After that they are refused like any foreign token and the browser starts a new session. Approvals signed with the old key fail at once, so pending protected actions ask again. - Webhook key: secrets encrypted with the previous key still decrypt and are re-encrypted with the new key as they are read. Delete
webhook-key.previousonce every webhook has been re-encrypted; until then a second webhook rotation is refused, because it would lose the secrets still under the first key.
Dashboard sign-in
In password mode (AUTH_MODE=password), sign-in and account routes are limited to 30 requests a minute per client address; the dashboard's status poll has its own budget of 600 a minute, so polling never uses up sign-in. Failed sign-ins are also counted per email, from any address: from the fifth failure each further try waits 1, 2, 4, 8… seconds, and the tenth in 15 minutes locks that email until the 15 minutes are over. Every refusal is the same generic 429 with Retry-After, and an unknown email takes as long to refuse as a wrong password, so neither reveals whether an account exists. Each successful sign-in is recorded in the audit log of every workspace the person belongs to as auth.signed_in with how they signed in (method: password, email_link, oauth or two_step when the two-step code completed it), never an IP address or user agent. Each account's failures are recorded in the audit log as auth.login_failed with a count, never the email typed or the password.
Workspace membership is audited: member.invited (the role and a keyed reference to the invitation, never its link), member.joined, member.role_changed (from and to), member.removed, team.created, team.renamed (old and new name), team.deleted (the workspace is restorable for 30 days), team.restored and auth.owner_setup. Inviting someone who already belongs to the workspace is refused (409 already_member); an older invitation accepted by someone who has joined since is used up without changing their role and recorded as member.invite_unneeded. A user may own at most five workspaces; the owner renames one with PUT /api/admin/teams/{id} {"name": "…"} (1–60 characters; Settings › Members › Workspaces) and deletes a workspace that has no Sites left with DELETE /api/admin/teams/{id} (delete its Sites first with DELETE /api/admin/sites/{id}, see Delete a Site; a user's last workspace is kept). The workspace's memberships, invitations and audit log go with it.
With AUTH_MODE=supabase (accounts on Supabase Auth; see Sign-in with Supabase) the same budgets apply to sign-in, and these entries are added: auth.signed_up (a new account's first workspace), auth.account_linked (a password-mode account linked to the Supabase account with the same verified email), auth.account_relinked (an email moved to a new Supabase account after the old one was deleted), auth.mfa_enabled, auth.mfa_disabled, auth.sessions_revoked ("Sign out everywhere") and member.email_changed (a confirmed email change, without either address). None of them holds an email, password, token, link, code or IP address.
Two-step verification and a lost device
Two-step verification (a code from an authenticator app) is optional for every member unless the server sets TELLTALE_MFA (owners covers owners and admins, all everyone). Settings › Members shows "Two-step on" beside each member who has it and how many members do, and nudges an owner who hasn't turned it on.
Lost the phone with your authenticator app? Email jad@heypharos.com from the address on your Telltale account. Support checks with your workspace's owner or an admin, removes the factor in Supabase Auth, and you sign in with your password or an email link and set two-step up again (Settings › Account). Nobody at Telltale can read your codes or your password.
Dashboard API reads (GET /api/admin/*) from another site's page are refused with 403 cross_site when the browser says so (Sec-Fetch-Site), and a request without a signed-in user gets 401 sign_in_required.
Keep identity, permission and authorization separate
- Browser identity: client.identify({userId, accountId}) labels telemetry with IDs your page supplies. It is unverified.
- Server-verified identity: your backend calls POST /api/v1/identify after checking its own session and account membership. Metering requires it.
- Collection permission: only your consent manager grants it, through optIn() and optOut(). Signing in never grants it.
- Authorization: your application decides every protected action. A verdict, an identity or a valid signature never authorizes anything on its own.
Better Auth adapter
A dependency-free Better Auth integration: better-auth.js for the browser and better-auth-server.js for your server. It is not an npm package: download the files into your application (for example lib/telltale/), next to the files they import. Server adapters lists each file's SHA-256 to compare before you use it.
mkdir -p lib/telltale && cd lib/telltale
curl -sSO https://app.trytelltale.dev/adapters/v1/better-auth.js
curl -sSO https://app.trytelltale.dev/adapters/v1/browser.js
curl -sSO https://app.trytelltale.dev/adapters/v1/identity-digest.js
curl -sSO https://app.trytelltale.dev/adapters/v1/better-auth-server.js
curl -sSO https://app.trytelltale.dev/adapters/v1/server.js
curl -sSO https://app.trytelltale.dev/adapters/v1/identifiers.jsThe browser imports better-auth.js (which imports browser.js and identity-digest.js); the server imports server.js (with identifiers.js) and better-auth-server.js (with identity-digest.js).
Browser side:
import { connectBetterAuth } from './lib/telltale/better-auth.js';
import { authClient } from './auth-client.js';
const client = await window.telltaleConsent.ready; // or Telltale.getClient() with the hosted tag, or the client from init()
const sync = connectBetterAuth({ client, authClient });
// On teardown of the auth owner (rare): sync.dispose();It subscribes to Better Auth's session signal, rereads the session with the cookie cache disabled, and associates the current user and active organization through your own route. A different user, a logout or an organization change resets the Telltale identity boundary before the replacement is associated. Withdrawal aborts pending association and forgets cached identity; reapproval reads the current session again, so no new login is needed and nothing from before the withdrawal is replayed. Auth changes never grant collection permission.
Server side, mount the association route inside the application that owns Better Auth:
import { createTelltaleServer } from './lib/telltale/server.js';
import { createBetterAuthAssociationHandler } from './lib/telltale/better-auth-server.js';
import { auth } from './auth.js';
const telltale = createTelltaleServer({
endpoint: process.env.TELLTALE_ENDPOINT,
serverKey: process.env.TELLTALE_SERVER_KEY
});
export const POST = createBetterAuthAssociationHandler({ auth, telltale, origin: process.env.APP_ORIGIN });The handler accepts only same-origin JSON, derives the user and active organization from the Better Auth session (never from the request body), confirms current organization membership, refuses with 412 identity_changed when the browser's identity hash no longer matches the signed-in account, and maps an expired browser session to 409 session_reinit_required so the browser can start a fresh session once.
The Better Auth adapter is adapted from an Apache-2.0 licensed Better Auth integration; its licence notice ships with Telltale.
Other auth libraries
Use connectIdentity from browser.js (download) with your own auth owner; the React and Next.js and the Vue and Nuxt guides show the wiring.