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

# Sessions & Authentication

> Authenticate API calls with JWT session tokens

# Sessions & Authentication

Session tokens are short-lived HS256 JWTs that prove a request originated
from a real merchant session inside the LaunchMyStore admin. Your app's
backend verifies them with the same `clientSecret` LaunchMyStore stored
when the merchant installed the app — no shared session store, no
round-trips back to LaunchMyStore for the common case.

## Flow

```mermaid theme={null}
sequenceDiagram
    participant App as Your app (iframe)
    participant SDK as App Bridge SDK
    participant LMS as LaunchMyStore host
    participant Bk  as Your backend

    App->>SDK: app.getSessionToken()
    SDK->>LMS: postMessage 'SESSION_TOKEN_REQUEST'
    LMS->>LMS: POST /api/apps/session-token<br/>(signs JWT with your clientSecret)
    LMS->>SDK: APP_BRIDGE_RESPONSE { token }
    SDK->>App: token (string)
    App->>Bk:  fetch('/api/x', { Authorization: 'Bearer <jwt>' })
    Bk->>Bk:   jwt.verify(token, clientSecret, { audience, issuer })
    Bk->>App:  200 OK
```

The crucial property: **the JWT signature is your proof**. Anyone in
possession of `clientSecret` can mint a token; the secret lives only on
the LaunchMyStore host and on your backend. Your frontend never sees it.

<Note>
  The minting endpoint (`POST /api/apps/session-token`, host-internal)
  requires only the app `apiKey` plus the admin session. A `domainSlug`
  field is **optional** — the host resolves the shop host from the
  backend's authoritative `storeURL`, so tokens mint correctly even when
  the admin session has no `domainslug` cookie.
</Note>

## Getting a Token

### Vanilla SDK

```javascript theme={null}
import { createApp } from '@launchmystore/app-bridge';

const app = createApp({
  apiKey: 'your-app-client-id',
  host:   new URLSearchParams(location.search).get('host'),
});

const token = await app.getSessionToken();
```

<Note>
  `App.getSessionToken()` is a thin wrapper over
  `dispatchAndWait('SESSION_TOKEN_REQUEST')` — it round-trips every call
  and does **not** cache. For caching + auto-refresh-when-near-expiry, use
  the `SessionToken` helper class or the `useSessionToken` React hook.
</Note>

```javascript theme={null}
import { SessionToken } from '@launchmystore/app-bridge/actions';

const session = SessionToken.create(app);

const token = await session.getToken();   // cached if >30s remaining; else round-trips
const fresh = await session.refresh();    // force a new token
```

### React hook

```jsx theme={null}
import { useSessionToken } from '@launchmystore/app-bridge-react';

function Reviews() {
  const { token, loading, error, getToken, refresh } = useSessionToken();

  const onFetch = async () => {
    const currentToken = await getToken();   // always returns a fresh-enough token
    const res = await fetch('/api/reviews', {
      headers: { Authorization: `Bearer ${currentToken}` },
    });
    return res.json();
  };

  if (loading) return <Loading />;
  if (error)   return <ErrorMsg msg={error.message} />;
  return <button onClick={onFetch}>Load</button>;
}
```

<Warning>
  Prefer `getToken()` over the `token` field. `token` is `null` until the
  first fetch resolves; calling `getToken()` always returns a valid (cached
  or freshly minted) token and avoids race conditions on mount.
</Warning>

### useAuthenticatedFetch

The shortest path — a `fetch` wrapper that injects the `Authorization`
header for you:

```jsx theme={null}
import { useAuthenticatedFetch } from '@launchmystore/app-bridge-react';

function Save() {
  const authFetch = useAuthenticatedFetch();

  const onClick = () =>
    authFetch('/api/save', { method: 'POST', body: JSON.stringify({ id }) });

  return <button onClick={onClick}>Save</button>;
}
```

## Token Structure

```javascript theme={null}
// Header
{ "alg": "HS256", "typ": "JWT" }

// Payload (issued by /api/apps/session-token)
{
  "iss":  "https://launchmystore.io",
  "dest": "https://{shop-host}",
  "aud":  "<your app's client ID>",
  "sub":  "<store id — the immutable store UUID; falls back to domainSlug if the store can't be resolved>",
  "exp":  1700086400,
  "iat":  1700000000,
  "nbf":  1700000000,
  "sid":  "<random UUID — unique per token>",
  "storeId": "<same immutable store UUID as sub, or null if unresolved>",
  "domainSlug": "<store slug>",
  "shop": "<shop host, no scheme>",
  "apiKey": "<your app's client ID — same value as aud>",
  "permissions": ["read_products", "write_orders", "..."],
  "scopes":      ["read_products", "write_orders", "..."]
}
```

| Claim                          | Meaning                                                                                                                                                                                                                                                   |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `iss`                          | Always `https://launchmystore.io` (use this when verifying).                                                                                                                                                                                              |
| `aud`                          | Your app's client ID. **Verify this matches** your installed app.                                                                                                                                                                                         |
| `sub`                          | **The immutable store identifier** (a UUID). Use this (or the identical `storeId`) as the primary key for every per-merchant record. Falls back to `domainSlug` only if the store can't be resolved — treat a non-UUID `sub` as an error and fail closed. |
| `storeId`                      | Same immutable UUID as `sub` (or `null` if unresolved). Either `sub` or `storeId` is the canonical tenant key.                                                                                                                                            |
| `dest` / `shop` / `domainSlug` | The merchant's storefront host. **Mutable** (custom domains / renames) — never use as a database key.                                                                                                                                                     |
| `exp` / `iat` / `nbf`          | Standard JWT timestamps.                                                                                                                                                                                                                                  |
| `sid`                          | Random UUID per token issuance — useful for audit/dedup logs.                                                                                                                                                                                             |
| `apiKey`                       | Your app's client ID again (duplicate of `aud`, kept for convenience).                                                                                                                                                                                    |
| `permissions` / `scopes`       | The granted-scope list at the time the token was minted, included as an **informational hint only** (`scopes` is a legacy alias — both arrays are identical). **Do not authorize from these claims.** See the authorization note below.                   |

<Warning>
  **Authorize against the OAuth installation's granted scopes, not the JWT
  `permissions` claim.** The `permissions` / `scopes` arrays in the session
  token are informational. The platform itself enforces scopes server-side on
  every `/api/v1/*` call against the installation's stored `grantedScopes`
  (presented via your OAuth **access token**, not the session JWT). For your
  own backend endpoints, authorize against the granted-scope list you persisted
  at install (see [Authentication → OAuth](/getting-started/authentication)),
  and fail closed if the installation is missing. Use the session token to
  **authenticate identity** (verify signature + `aud` + `exp`/`nbf` + `iss`, then
  key off `sub`/`storeId`), not to make authorization decisions from its scope
  claim.
</Warning>

## Token Lifecycle

* **Lifetime**: tokens expire 24 hours after issuance (`exp = iat + 86400`). Don't hard-code this — always read `exp` from the token and refresh when it's near expiry.
* **Caching**: the SDK's `SessionToken.create(app)` and the
  `useSessionToken` hook keep the token in memory until **30 seconds**
  before expiry — call `getToken()` before every authenticated request
  and the helper does the right thing.
* **Forced refresh**: call `session.refresh()` (vanilla) or `refresh()`
  (hook) to drop the cache and request a new token immediately. Useful
  after permission changes or before a long-running operation.

## Verifying Tokens

Always verify on your backend with the same `clientSecret` you received
during OAuth install.

### Node.js (Express)

```javascript theme={null}
import jwt from 'jsonwebtoken';

function requireSession(req, res, next) {
  const auth = req.headers.authorization;
  if (!auth?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'missing_token' });
  }

  try {
    const claims = jwt.verify(auth.slice(7), process.env.APP_CLIENT_SECRET, {
      algorithms: ['HS256'],
      audience:   process.env.APP_CLIENT_ID,
      issuer:     'https://launchmystore.io',
    });

    req.session = {
      merchantId:  claims.sub,
      shopOrigin:  claims.dest,
      permissions: claims.permissions || [],
      jti:         claims.sid,
    };
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'token_expired' });   // client should refresh
    }
    return res.status(401).json({ error: 'invalid_token' });
  }
}

app.get('/api/orders', requireSession, async (req, res) => {
  const orders = await db.orders.findByMerchant(req.session.merchantId);
  res.json(orders);
});
```

<Note>
  Use `issuer: 'https://launchmystore.io'` (the actual claim). Earlier
  docs showed `issuer: 'launchmystore'` — that string will fail
  verification.
</Note>

### Python (Flask)

```python theme={null}
import jwt, os
from functools import wraps
from flask import request, jsonify

def require_session(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        auth = request.headers.get('Authorization', '')
        if not auth.startswith('Bearer '):
            return jsonify(error='missing_token'), 401
        try:
            claims = jwt.decode(
                auth[7:],
                os.environ['APP_CLIENT_SECRET'],
                algorithms=['HS256'],
                audience=os.environ['APP_CLIENT_ID'],
                issuer='https://launchmystore.io',
            )
        except jwt.ExpiredSignatureError:
            return jsonify(error='token_expired'), 401
        except jwt.InvalidTokenError:
            return jsonify(error='invalid_token'), 401

        request.merchant_id = claims['sub']
        request.permissions = claims.get('permissions', [])
        return f(*args, **kwargs)
    return wrapper

@app.route('/api/orders')
@require_session
def list_orders():
    return jsonify(db.orders.find(merchant_id=request.merchant_id))
```

### PHP (firebase/php-jwt)

```php theme={null}
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

function verifySession(string $token): object {
    $claims = JWT::decode($token, new Key(getenv('APP_CLIENT_SECRET'), 'HS256'));

    if ($claims->aud !== getenv('APP_CLIENT_ID')) throw new Exception('aud mismatch');
    if ($claims->iss !== 'https://launchmystore.io') throw new Exception('iss mismatch');

    return $claims;
}
```

## Common Patterns

### Manual fetch wrapper (vanilla SDK)

```javascript theme={null}
import { createApp } from '@launchmystore/app-bridge';
import { SessionToken } from '@launchmystore/app-bridge/actions';

const app     = createApp({ apiKey, host });
const session = SessionToken.create(app);

async function authenticatedFetch(url, init = {}) {
  const token = await session.getToken();
  return fetch(url, {
    ...init,
    headers: {
      ...init.headers,
      Authorization:  `Bearer ${token}`,
      'Content-Type': init.body ? 'application/json' : undefined,
    },
  });
}
```

### React Query

```jsx theme={null}
import { useQuery } from '@tanstack/react-query';
import { useAuthenticatedFetch } from '@launchmystore/app-bridge-react';

function useProducts() {
  const authFetch = useAuthenticatedFetch();
  return useQuery({
    queryKey: ['products'],
    queryFn:  () => authFetch('/api/products').then((r) => r.json()),
  });
}
```

### Axios

```javascript theme={null}
import axios from 'axios';
import { createApp } from '@launchmystore/app-bridge';
import { SessionToken } from '@launchmystore/app-bridge/actions';

const session = SessionToken.create(createApp({ apiKey, host }));

const api = axios.create({ baseURL: '/api' });
api.interceptors.request.use(async (config) => {
  config.headers.Authorization = `Bearer ${await session.getToken()}`;
  return config;
});
```

## Error Handling

`app.getSessionToken()` / `session.getToken()` reject with the SDK's
generic `Error` shape — there is **no `error.code`**. Branch on
`error.message` if you need to distinguish causes:

```javascript theme={null}
try {
  const token = await app.getSessionToken();
} catch (err) {
  if (err.message.includes('timeout')) {
    // Host took longer than 10s — typically not in admin, or host JS errored
    showFallbackUI();
  } else {
    showError('Auth error. Please reload.');
  }
}
```

Backend verification errors map cleanly:

| `jsonwebtoken` error                | HTTP status | Client action                                                      |
| ----------------------------------- | ----------- | ------------------------------------------------------------------ |
| `TokenExpiredError`                 | 401         | Call `refresh()` and retry the request once.                       |
| `JsonWebTokenError` (bad signature) | 401         | Stop retrying — likely wrong `clientSecret` or token was tampered. |
| `NotBeforeError`                    | 401         | Server clock is skewed — verify NTP.                               |
| `aud` / `iss` mismatch              | 401         | Mismatched env vars; never retry.                                  |

## Debugging

`atob`-decoding the middle segment of a JWT is safe for local inspection
(it skips signature verification — never trust it for auth decisions):

```javascript theme={null}
function peek(token) {
  const [, payload] = token.split('.');
  return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
}

const token = await app.getSessionToken();
console.log(peek(token));
// { iss: 'https://launchmystore.io', aud: '...', sub: '...', exp: ..., permissions: [...] }
```

Useful when a request 401s in production: log `peek(token).exp - Date.now()/1000`
to see if you have a clock-skew problem.

## Security Best Practices

<AccordionGroup>
  <Accordion title="Never embed clientSecret in your frontend">
    The secret lives on the LaunchMyStore host (to mint tokens) and your
    backend (to verify them). If you put it in JavaScript that ships to
    the browser, anyone can forge a token.
  </Accordion>

  <Accordion title="Always scope by sub">
    Every backend query that touches merchant data MUST be scoped by
    `claims.sub`. Treat session tokens like row-level security tokens.
  </Accordion>

  <Accordion title="Verify aud and iss explicitly">
    The signature alone isn't enough — a token issued for another app
    (different `aud`) or by a different host (different `iss`) is a valid
    JWT but a wrong one.
  </Accordion>

  <Accordion title="Authorize from granted scopes, not the JWT claim">
    The session token's `permissions` / `scopes` arrays are an
    informational hint, not the authorization source. Authorize against
    the installation's **granted scopes** — the platform enforces them
    server-side on every `/api/v1/*` call (via your OAuth access token),
    and for your own endpoints you should check the granted-scope list you
    persisted at install. Fail closed if the installation is missing.
  </Accordion>

  <Accordion title="HTTPS only">
    Tokens grant access to merchant data — transmit only over TLS.
  </Accordion>

  <Accordion title="Don't log tokens">
    The signature is private; never write tokens to logs, exception
    reports, or telemetry.
  </Accordion>
</AccordionGroup>

## See Also

* [Authentication](/getting-started/authentication) — OAuth flow for getting `clientSecret`.
* [Actions Reference](/app-bridge/actions) — `SESSION_TOKEN_REQUEST` payload shape.
* [React Hooks](/app-bridge/react-hooks#authentication) — `useSessionToken`, `useAuthenticatedFetch`, `useAppQuery`, `useAppMutation`.
