Authentication
Frankie never sees your session cookie, and your backend never sees Frankie's tokens. The handshake is three hops.
| Credential | Where it lives | What it does |
|---|---|---|
| Publishable key | The browser bundle | Names the workspace. Authorises nothing on its own. |
| Allowed origins | The workspace settings | Decide which sites may open a session. Exact match, no wildcards. |
| User token | Signed by your backend | Says who the user is. Frankie verifies the signature and reads sub and claims. |
1. Your backend signs a user token
The widget calls an endpoint on your domain. You authenticate it with whatever you already use (a cookie, NextAuth, Clerk, a session header) and sign a short-lived JWT.
const token = await new SignJWT({ name: user.name, email: user.email, plan: user.plan })
.setProtectedHeader({ alg: "HS256" })
.setSubject(user.id) // required: becomes the Frankie user id
.setIssuedAt()
.setExpirationTime("5m") // short: it is exchanged immediately
.sign(new TextEncoder().encode(process.env.FRANKIE_JWT_SECRET!));
Required claim: sub. Optional and used if present: name (or preferred_username) and email. Other claims can be forwarded to your API.
JWKS instead of a shared secret
If you'd rather not share an HS256 secret, publish a JWKS endpoint and sign with RS256 or ES256. Frankie fetches and caches your keys and verifies against them.
2. Frankie exchanges it for a session token
POST /v1/session
x-frankie-key: pk_live_…
Origin: https://app.acme.com
{ "userToken": "eyJ…", "context": { "route": "/settings" } }
Frankie checks, in order:
- Origin against the allowed origins: exact origin match, scheme and port included. No wildcards: the list is short and known, and a wildcard is how a look-alike domain gets in.
- Publishable key, compared in constant time.
- Rate limit per client IP.
- User token signature, expiry and
sub.
It replies with its own session token, scoped to exactly one conversation.
3. The widget chats with the session token
POST /v1/chat
Authorization: Bearer <session token>
x-frankie-key: pk_live_…
{ "message": "how do I connect a repo?" }
The response is a text/event-stream: text as it's written, each action as it starts and ends, any action that was blocked, and a final event with usage. When a session token expires, the widget gets a 401, drops it, and quietly repeats step 1 on the next send.
Acting as the user
When identity forwarding is on for an API (usually Authorization), every action carries the user's own token, so your existing authorisation runs unchanged and the agent can only do what that user could do.
The browser sends your token only once, in POST /v1/session. Later /v1/chat requests carry only Frankie's session token; Frankie keeps your token for the session and attaches it to each action. Your API and your token endpoint must use the same signing key (or compatible JWKS validation).
If an action reports 401, check in this order:
- Start a new conversation, so no old session is reused.
- Check that
POST /v1/sessionreturns a signed-in user, not an anonymous one. - Check that identity forwarding is on for the API, and that its base URL is the current API.
- Check that your API has the matching signing secret, and was redeployed after it changed.
Never paste tokens or signing secrets into issue threads, chat transcripts or logs.
Anonymous visitors
For public pages, allow anonymous chat and leave out getUserToken. Frankie issues a pseudonymous id, and the agent is told the visitor isn't signed in and must not take account actions. Nothing is forwarded to your API, which sees an unauthenticated request, so design anything you expose to visitors accordingly.