Skip to content

Securing your app

The widget runs in your users’ browsers, and the agent acts on your systems. Everything that crosses between the two has to be treated as coming from an untrusted place. This page describes the pattern to use and the reasoning behind it, so you can implement it in whatever language your backend is written in.

Three facts decide the whole design.

The widget token is public. It sits in your page’s HTML or your bundle. Anyone who opens dev tools can read it. It identifies the widget; it does not authenticate the visitor.

The origin allowlist is a browser control, not authentication. Urai checks the Origin header of every widget request, including the stream, against the widget’s allowed origins. That stops another website from embedding your widget. It does not stop somebody sending requests from outside a browser, because Origin is a header like any other.

The visitor id is asserted, not verified. Your page sends the visitor id and Urai treats it as opaque text, never resolving it against any user table. Conversations are isolated per widget and visitor id, so anyone who can guess another visitor’s id can read that visitor’s threads from an allowed origin.

Use opaque, unguessable ids for that reason. A random UUID stored on the account is fine. Sequential integers, email addresses, and usernames are not, because they are guessable by design.

The same logic applies to vars. They travel from the browser and are stored on the conversation thread, so a user can read them and can change them.

The conclusion to carry through the rest of this page: nothing the browser sends may decide what the agent is allowed to do. Authorization has to rest on something your backend minted and your backend verifies.

The pattern: short-lived signed session tokens

Section titled “The pattern: short-lived signed session tokens”

Your backend already knows who the user is. Have it say so in a form your API can check later, hand that to the widget as context, and make the agent present it on every call.

  1. The user loads a page. Your backend authenticates them the way it always does, with a session cookie, an SSO assertion, or whatever you use.
  2. Your backend checks that this user is allowed to use the agent at all.
  3. It mints a short-lived signed token naming the tenant, the subject, and the role, signed with a secret only your backend holds.
  4. The page passes that token to the widget as a var.
  5. The agent’s library reads it from meta.vars and sends it to your API in an Authorization header.
  6. Your API verifies the signature and the expiry, and takes the tenant and role from the verified payload only.

The token is browser-visible by design. That is acceptable because it carries no secret, expires in minutes, and cannot be altered without invalidating the signature. It is a claim your backend made about a user, not a key.

Keep the payload small and self-describing:

{
"tenant": "org_8f2c1a",
"sub": "user_41d0",
"role": "manager",
"exp": 1775030400
}

A compact wire format that is easy to produce and parse in any language:

acmew_<base64url(payload)>.<base64url(HMAC-SHA256(secret, payload))>

A standard JWT with HS256 works equally well and every language has a library for it. Either way the properties that matter are the same:

  • Signed, so the payload cannot be edited. HMAC-SHA256 with a shared secret when the same service mints and verifies; RS256 or ES256 when a different service verifies, so only the minter needs the private key.
  • Short lived. Five to fifteen minutes. The page refreshes before expiry.
  • Prefixed, so your API can tell this credential apart from a long-lived machine API key and route it to the right verification path.
  • Not encrypted. Base64 is encoding, not protection. Put nothing in the payload you would not show the user.

Two implementation details that are easy to miss: compare signatures in constant time so you do not leak information through timing, and make verification return “invalid” for malformed input rather than raising an error that reaches the caller.

Rotating the signing secret invalidates every live token. That is a useful emergency control, and it means routine rotation should accept both the old and the new secret during a short overlap.

One endpoint on your backend, reachable only by an authenticated human session:

GET /internal/assistant-token
Cookie: <your normal session>
200 OK
{ "token": "acmew_eyJ0ZW5hbnQ...", "expiresAt": 1775030400 }

Rules for it:

  • Authenticate the session first, and reject anything that is not a human session. This endpoint must never accept a widget token or an API key, otherwise a token can extend itself forever.
  • Check the user’s permission to use the agent before minting. If your app has roles, this is the same check that decides whether to render the widget.
  • Read the tenant and the user from your own authorization store. Never from request parameters, even convenient ones.
  • Mint the narrowest role that can do agent work. It does not need to be the user’s full role.
  • Rate limit it per session.
  • Return the token and its expiry, nothing else.

Put the token in vars and refresh it before it expires:

// Values fetched from your backend, kept in memory only.
widget.setVars({
session_token: token,
route: location.pathname,
});

Schedule the refresh with a lead time of about a minute, and call setVars again with the new token. Keep the token in memory rather than in localStorage or sessionStorage; it is short lived and there is no reason for it to outlive the page.

When the user signs out or switches tenant, stop refreshing and change the visitor id. Changing the id resets the conversation, which is what you want, because the previous user’s thread must not be visible to the next one.

Your API sees agent traffic as ordinary HTTP requests carrying a bearer token:

POST /api/invoices/INV-2041/refund
Authorization: Bearer acmew_eyJ0ZW5hbnQ...

Verification, in whatever language:

token = bearer credential from the Authorization header
if token does not start with your prefix -> fall through to your other auth paths
claims = verify_signature_and_expiry(token, secret)
if claims is null -> 401
tenant = claims.tenant # from the payload, never the request
role = claims.role
if role is not one you recognise -> treat as least privilege
authorize(action, tenant, role) # the same check a human request gets

The line that matters most is the one taking the tenant from the claims. If any part of your code reads a tenant id from the request body, a query parameter, or a var, an agent can be talked into acting on the wrong tenant’s data. Send the tenant id in vars if it is useful for display or debugging, but never let it decide anything.

A verified token tells you who the agent is acting for. It does not mean the agent should be able to do everything that user can do.

Give the token its own role. Agent work is usually a subset: read records, create a draft, update a status. Mint that subset rather than the user’s full permissions.

Keep destructive and administrative actions human-only. Managing members and roles, issuing API keys, changing billing, deleting data permanently. Record which authentication method a request arrived with, and reject non-human methods on those routes explicitly. This is a separate check from the role, because it answers a different question.

Re-check on every request. Do not assume a token was minted correctly. Unknown or missing roles fall back to least privilege rather than to a default that happens to work.

Treat agent input as user input. The parameters come from a model that constructed them from a conversation. Validate types, ranges, and ownership the same way you would for a form submission. The model can be talked into passing whatever a user asks it to pass.

Make writes idempotent. An agent may retry after an error or a timeout. Accept an idempotency key on anything that creates or charges, and return the original result on a repeat.

Rate limit per tenant and per token. A loop in an agent turn is a plausible accident, and the step cap alone will not protect a slow endpoint.

Log the authentication method. When you audit what happened, “which of these changes were made by the agent” is the first question you will ask.

Prefer narrow endpoints. An endpoint that refunds one invoice is easier to reason about than a generic one that accepts an action name. This also produces better agent behavior, because the intent is clearer.

Configure these regardless of what your API does, so a mistake in one place does not become an incident.

Origin allowlist. Every origin that embeds the widget, with the scheme, and never * outside local development.

Token rotation. Rotate the widget token if it appears somewhere it should not, and deploy the new snippet in the same window, because the old token stops working immediately.

Network allowlist. On the agent’s Security tab, list the hosts the agent’s code may reach. An empty list means unrestricted, which also means a prompt injection in a document can send data anywhere.

Allowed secrets. Only the secrets this agent needs. Secrets not on the list do not resolve, so an agent cannot read a credential you forgot to think about.

Step cap. Bounds a single turn. Set it as low as the work allows.

Vars hygiene. Vars are stored on the thread and visible to anyone who can read that conversation in the console. Put the session token, the current route, and a little display context in there. Do not put personal data you would not otherwise store, and never put an API key, a database URL, or any long-lived credential.

Commands sent by the agent arrive in your page as JSON that a model composed. Validate the shape, match against an allowlist of actions you support, and restrict values: in-app paths only for navigation, known element ids only for scrolling, and no value ever reaching eval, innerHTML, or a redirect that accepts absolute URLs.

A command should never perform an action the user could not perform themselves in your UI. The agent runs on the server, but the command executes in a session that belongs to the user.

  • The visitor id is opaque and unguessable.
  • Production origins are allowlisted and * is not.
  • A short-lived signed token carries identity from your backend to your API.
  • The minting endpoint accepts human sessions only and checks permission first.
  • Tenant and role come from the verified payload, never from vars or parameters.
  • The token’s role is the narrowest one that can do agent work.
  • Administrative and destructive routes reject non-human authentication methods.
  • Agent parameters are validated like any other user input.
  • Writes are idempotent and rate limited.
  • The agent’s network allowlist and allowed secrets are both non-empty and minimal.
  • Vars contain no secrets and no data you would not store.
  • Command handlers match an allowlist and never evaluate what they receive.
  • Requests log which authentication method they arrived with.