> ## Documentation Index
> Fetch the complete documentation index at: https://docs.salesive.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Session tokens

> Prove which store and user is calling your embedded app's backend with a short lived signed JWT, and install without the OAuth redirect using token exchange.

When your app runs inside the Salesive dashboard, its front end can ask the dashboard for a
**session token**: a short lived JWT, signed by Salesive, that names the store and the signed
in user. Your front end sends it to your own backend, and your backend verifies it. This is
the reliable way to know who is calling. The `?shop=` and `?user=` launch parameters are
convenient, but anyone can type them into a URL.

With **token exchange**, your backend can also trade a session token for a normal app access
token. An embedded app then needs no OAuth redirect inside the iframe at all.

## How it fits together

1. Your front end calls `getSessionToken()` from the App Bridge.
2. The dashboard asks the Salesive API to mint a token for your installation and the signed
   in merchant, and posts it back to your iframe.
3. Your front end sends it on each request: `Authorization: Bearer <token>`.
4. Your backend verifies it with the Salesive public key and reads the store from `dest`.
5. Optional: the first time your backend sees a store it holds no access token for, it trades
   the session token for one (token exchange).

## Get a token in the front end

```js theme={null}
import { getSessionToken } from "salesive-dev-tools/permissions";

async function api(path, init = {}) {
  const token = await getSessionToken();
  return fetch(path, {
    ...init,
    headers: { ...init.headers, Authorization: `Bearer ${token}` },
  });
}
```

Tokens last **60 seconds**. The bridge caches the current token and fetches a new one about 10
seconds before it expires, so call `getSessionToken()` before every request rather than
storing the token yourself.

`getSessionToken({ timeoutMs, forceRefresh })` rejects with a `SessionTokenError` whose `code`
is:

| Code | Meaning |
| - | - |
| `not_embedded` | The page is not running inside the Salesive dashboard. |
| `timeout` | The dashboard did not answer in time (default 10 seconds). |
| `not_installed` | A developer preview opened from the editor on a store where the app is not installed. |
| `forbidden`, `unauthenticated`, `unavailable` | The dashboard could not mint a token. |

The cached token is dropped when the merchant logs out or switches store.

## Token format

The token is a compact JWS signed with **ES256** (ECDSA P-256, SHA-256).

```json theme={null}
{
  "alg": "ES256",
  "typ": "JWT",
  "kid": "<key id>"
}
```

```json theme={null}
{
  "iss": "https://api.salesive.com",
  "dest": "664f1c2b8e1a2b3c4d5e6f70",
  "aud": "app_3f9c1e0b7a2d4c6e8f1a3b5c7d9e0f12",
  "sub": "65a0d4e1f2c3b4a5968778a1",
  "exp": 1762344060,
  "nbf": 1762344000,
  "iat": 1762344000,
  "jti": "9b1f0c4e6a7d48b2a3c5e7f901d2b4c6",
  "sid": "4e7a1c9b0d2f4a6c8e1b3d5f7a9c0e2b",
  "installation": "6650a3e2c1b4d5e6f7a8b9c0",
  "role": "staff",
  "env": "production",
  "shop": "664f1c2b8e1a2b3c4d5e6f70",
  "purpose": "session"
}
```

| Claim | Meaning |
| - | - |
| `iss` | The Salesive API that issued the token. The same value as `issuer` in the [discovery document](https://docs.salesive.com/apps/oauth-install.md#endpoints). |
| `dest` | The store id. **This is the store the request is for.** |
| `aud` | Your app's client id. Your backend must reject any other value. |
| `sub` | The Salesive user id of the person using the dashboard. Owners and staff both get tokens. |
| `exp` | Expiry, 60 seconds after `iat`. |
| `nbf`, `iat` | Not before and issued at (both the mint time). |
| `jti` | A unique id for this token. Token exchange accepts each `jti` once. |
| `sid` | Stable for one dashboard login and different after the merchant logs in again. Useful to end your own session when theirs ends. |
| `installation` | Your installation id on this store. |
| `role` | `owner` or `staff`. Use it to hide owner only features from staff. |
| `env` | `production` or `development`, the [environment](https://docs.salesive.com/apps/building-publishing.md) this install runs in. |
| `shop` | The same value as `dest`. |
| `purpose` | Always `session` for these tokens. Reject anything else on routes that expect a session token. The same key also signs the tokens Ola sends to your [MCP server](https://docs.salesive.com/apps/mcp-server-tools.md), with `purpose: "mcp"`. |

## Verify a token on your backend

Fetch the public key set once and cache it:

```bash theme={null}
curl https://api.salesive.com/api/v1/oauth/jwks
```

```json theme={null}
{
  "keys": [
    { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "kid": "...", "alg": "ES256", "use": "sig" }
  ]
}
```

Then, for every request:

1. Split the token into header, payload and signature.
2. Require `alg` to be `ES256`. Never let the token choose the algorithm.
3. Find the key whose `kid` matches. If none does, refetch the key set once (Salesive may have
   rotated its key), then give up.
4. Verify the signature over `header.payload`.
5. Require `aud` to equal **your client id**. Salesive signs every app's tokens with the same
   key, so skipping this check would let another app's token into your backend.
6. Require `iss` to equal the Salesive API base you call.
7. Check `exp` and `nbf`, allowing at most a few seconds of clock drift.
8. Require `purpose` to be `session`.

A dependency free Node verifier (this is what the [app starter](https://docs.salesive.com/apps/app-starter.md) ships in
`server/sessionToken.js`):

```js theme={null}
import crypto from "node:crypto";

const API = "https://api.salesive.com";
const CLIENT_ID = process.env.SALESIVE_CLIENT_ID;
let keys = new Map();

async function loadKeys() {
  const res = await fetch(`${API}/api/v1/oauth/jwks`);
  const { keys: list } = await res.json();
  keys = new Map(
    list.map((k) => [k.kid, crypto.createPublicKey({ key: { kty: k.kty, crv: k.crv, x: k.x, y: k.y }, format: "jwk" })]),
  );
}

export async function verifySessionToken(token) {
  const [h, p, s] = String(token).split(".");
  const header = JSON.parse(Buffer.from(h, "base64url"));
  const claims = JSON.parse(Buffer.from(p, "base64url"));
  if (header.alg !== "ES256") throw new Error("unexpected alg");

  if (!keys.has(header.kid)) await loadKeys();
  const key = keys.get(header.kid);
  if (!key) throw new Error("unknown key");

  const ok = crypto.verify("sha256", Buffer.from(`${h}.${p}`), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(s, "base64url"));
  if (!ok) throw new Error("bad signature");

  const now = Math.floor(Date.now() / 1000);
  if (claims.aud !== CLIENT_ID) throw new Error("wrong audience");
  if (claims.iss !== API) throw new Error("wrong issuer");
  if (now > claims.exp + 5 || now + 5 < claims.nbf) throw new Error("expired or not yet valid");
  if (claims.purpose !== "session") throw new Error("wrong purpose");
  return claims; // claims.dest is the store, claims.sub the user
}
```

Any standard JWT library works too, for example `jose`:

```js theme={null}
import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(new URL("https://api.salesive.com/api/v1/oauth/jwks"));
const { payload } = await jwtVerify(token, jwks, {
  algorithms: ["ES256"],
  audience: process.env.SALESIVE_CLIENT_ID,
  issuer: "https://api.salesive.com",
  clockTolerance: 5,
});
if (payload.purpose !== "session") throw new Error("wrong purpose");
```

> [!WARNING]
>
> A session token proves **who is calling**. It does not carry API access. To call the
> Salesive API, use your app access token (from the install flow or token exchange below).

## Token exchange: install without the redirect

The merchant already approved your app's scopes when they installed it from the marketplace.
Token exchange lets your backend turn a session token into an app access token for that
installation, without sending the merchant through the consent redirect again inside the
iframe.

```bash theme={null}
curl -X POST https://api.salesive.com/api/v1/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d subject_token=<session token> \
  -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
  -d client_id=$SALESIVE_CLIENT_ID \
  -d client_secret=$SALESIVE_CLIENT_SECRET
```

```json theme={null}
{
  "access_token": "app_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "app_rt_...",
  "scope": "READ_ORDERS WRITE_NOTES",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}
```

The response is the same token pair the [redirect install](https://docs.salesive.com/apps/oauth-install.md) returns, so
refresh and revoke work as usual. The exchange is refused (`400` or `401`) when:

* the client secret is wrong or missing (exchange is for confidential clients only),
* the token was issued for another app (`aud`), has expired, or was already exchanged (`jti`),
* the app is no longer installed on that store.

Good practice:

* **Exchange once per store, then refresh.** Store the access and refresh tokens and only
  exchange when you hold none for the store (first open, or after an uninstall and reinstall).
  Every exchange issues a new token pair.
* **Use a fresh token for the exchange** (`getSessionToken({ forceRefresh: true })`), since a
  token can be exchanged only once.
* The access token acts for the store, with the scopes granted at install. It is not tied to
  the user in `sub`; use `sub` and `role` for your own user model.

## Security notes

* The token is delivered only to your app's origin (the dashboard posts it to the origin of
  your `appUrl`), and lives 60 seconds.
* Never log full tokens, and never put them in URLs you control.
* Keep using the `?shop=` parameter only as a hint for which store to show first. Decide
  access from `dest`.

## Next steps

> [card] [App Bridge](https://docs.salesive.com/apps/app-bridge.md)
>
> The full bridge reference, including getSessionToken.

> [card] [App starter](https://docs.salesive.com/apps/app-starter.md)
>
> A working app with the verifier and the exchange wired in.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.