Skip to main content

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

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.

Getting a Token

Vanilla SDK

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();
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.
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

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>;
}
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.

useAuthenticatedFetch

The shortest path — a fetch wrapper that injects the Authorization header for you:
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

// 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 Accounts UUID; falls back to domainSlug if the store can't be resolved>",
  "exp":  1700003600,
  "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>",
  "permissions": ["read_products", "write_orders", "..."],
  "scopes":      ["read_products", "write_orders", "..."]
}
ClaimMeaning
issAlways https://launchmystore.io (use this when verifying).
audYour app’s client ID. Verify this matches your installed app.
subThe immutable store identifier (internal Accounts 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.
storeIdSame immutable UUID as sub (or null if unresolved). Either sub or storeId is the canonical tenant key.
dest / shop / domainSlugThe merchant’s storefront host. Mutable (custom domains / renames) — never use as a database key.
exp / iat / nbfStandard JWT timestamps.
sidRandom UUID per token issuance — useful for audit/dedup logs.
permissions / scopesThe 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.
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), 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.

Token Lifecycle

  • Lifetime: tokens expire one hour after issuance (exp = iat + 3600).
  • 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)

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);
});
Use issuer: 'https://launchmystore.io' (the actual claim). Earlier docs showed issuer: 'launchmystore' — that string will fail verification.

Python (Flask)

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)

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)

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

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

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:
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 errorHTTP statusClient action
TokenExpiredError401Call refresh() and retry the request once.
JsonWebTokenError (bad signature)401Stop retrying — likely wrong clientSecret or token was tampered.
NotBeforeError401Server clock is skewed — verify NTP.
aud / iss mismatch401Mismatched 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):
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

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.
Every backend query that touches merchant data MUST be scoped by claims.sub. Treat session tokens like row-level security tokens.
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.
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.
Tokens grant access to merchant data — transmit only over TLS.
The signature is private; never write tokens to logs, exception reports, or telemetry.

See Also