Skip to content

Embedded App Authentication ​

Last updated View as Markdown

Embedded apps load inside the Seller Area as an iframe. There is no authorization code in the URL — the frontend calls qantra.sessionToken() at runtime to obtain a short-lived JWT, which your server exchanges for a store access token.

Tip

If you create your app using the Nuxt Starter Template via the YouCan CLI, the entire auth flow (Qantra, JWT exchange, session persistence) is already fully implemented for you.

If you're building an external app that opens in its own browser tab, see External App Authentication instead.

Core Concepts ​

TermDescription
Session ID (session)A stable, deterministic identifier for the (app × seller × store) combination. Passed in the launch URL as ?session=. Used as your DB key — not an exchangeable credential.
Session TokenA short-lived JWT signed by YouCan. Obtained at runtime via qantra.sessionToken() — it is not in the URL. This is what is exchanged for an access token.
Access TokenA bearer token for the YouCan Store Admin API, offline or online. See Token types.
HMACA SHA-256 signature appended to the launch URL, proving it came from YouCan.
QantraYouCan's communication bridge between the Seller Area (parent window) and your app (iframe).

The Launch URL ​

When a seller opens your embedded app, the Seller Area builds a signed URL:

https://your-app.com/?timestamp=...&session=SID&store=my-store&seller=ID&locale=en&embedded=1&host=bXktc3RvcmUueW91Y2FuLnN0b3JlL2FkbWlu&hmac=SIG

The session param is only a DB lookup key. The actual session JWT is fetched at runtime via qantra.sessionToken().

The store admin (my-store.youcan.store/admin) is sent base64-encoded in host. When your app is loaded outside the Seller Area, redirect to https://<decoded host>/apps/<your app handle>/app.


Step 1 — HMAC Verification ​

Always verify the HMAC before doing anything else. An invalid HMAC means the request didn't come from YouCan.

  1. Take all query params from the URL except hmac.
  2. Build a query string (in the same order as received).
  3. Compute HMAC-SHA256(query_string, CLIENT_SECRET).
  4. Compare using constant-time comparison.

Warning

Never use == or === to compare signatures. Use constant-time comparison to prevent timing attacks.

js
const crypto = require('crypto');

function verifyHmac(queryString, clientSecret) {
  const params = new URLSearchParams(queryString);
  const received = params.get('hmac');
  params.delete('hmac');

  const message = params.toString();
  const expected = crypto.createHmac('sha256', clientSecret).update(message).digest('hex');

  const a = Buffer.from(received ?? '', 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Step 2 — Obtain a Session Token via Qantra ​

How qantra.sessionToken() works ​

Qantra uses postMessage to communicate with the Seller Area, which calls its own internal API to generate and return the JWT:

Auto-injection via qantra.initialize() ​

Calling qantra.initialize() patches window.fetch so all same-origin requests automatically include:

http
Authorization: Bearer <live session JWT>
X-Requested-With: XMLHttpRequest

Your server reads the Authorization: Bearer header on every request — no manual token wiring needed. If your server responds with x-youcan-retry-invalid-session-request, Qantra calls qantra.sessionToken() again and retries the request once automatically.


Step 3 — Exchange the Session Token ​

Server middleware (Node.js / Express) ​

js
const jwt = require('jsonwebtoken');
const { exchangeSessionToken, db } = require('./lib');

async function sessionMiddleware(req, res, next) {
  const authHeader = req.headers['authorization'];
  if (!authHeader) return next();

  const token = authHeader.replace('Bearer ', '');
  const payload = jwt.verify(token, process.env.YOUCAN_API_SECRET, {
    audience: process.env.YOUCAN_API_KEY,
  });

  if (payload.iss !== `${payload.dest}/admin`) {
    return res.status(401).end();
  }

  let session = await db.session.findOne({ id: payload.sid });

  if (!session) {
    session = await db.session.create({ id: payload.sid, store: payload.str });
  }

  if (!session.accessToken) {
    const { access_token } = await exchangeSessionToken(token);
    session = await db.session.update({ id: payload.sid, accessToken: access_token });
  }

  req.session = session;
  next();
}

Token exchange request ​

js
async function exchangeSessionToken(sessionToken, type = 'offline') {
  const res = await fetch('https://api.youcan.shop/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
      client_id: process.env.YOUCAN_API_KEY,
      client_secret: process.env.YOUCAN_API_SECRET,
      subject_token: sessionToken,
      subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
      requested_token_type: `urn:youcan:params:oauth:token-type:${type}-access-token`,
    }),
  });

  return res.json(); // { access_token, expires_in }
}
ParameterValue
grant_typeurn:ietf:params:oauth:grant-type:token-exchange
subject_tokenThe session token from qantra.sessionToken()
subject_token_typeurn:ietf:params:oauth:token-type:id_token
requested_token_typeurn:youcan:params:oauth:token-type:offline-access-token (default) or urn:youcan:params:oauth:token-type:online-access-token

grant_type=token_exchange with a session_token parameter is still accepted, with the same requested_token_type.

Token types ​

OfflineOnline
Tied toThe storeThe person who opened your app
LifetimeNo expiry24 hours, no refresh token
ScopesYour app's scopesYour app's scopes, cut down to the person's store permissions
UseWebhooks, background jobs, syncsRequests made for the person in your app's UI
  • You get the same offline token on every exchange for one store.
  • The offline token's scopes are updated to your app's current scopes on each exchange.
  • Do not revoke the offline token when a session ends. A revoked token cannot be used anywhere in your app until you exchange again.
  • A request with an online token outside the person's permissions is answered with 401.

You get the person in the response of an online exchange:

json
{
  "token_type": "Bearer",
  "expires_in": 86400,
  "access_token": "...",
  "associated_user_scope": "read-orders write-orders",
  "associated_user": {
    "id": "id_2Ada0Lovelace0Example0Id00",
    "first_name": "Ada",
    "last_name": "Lovelace",
    "email": "ada@example.com",
    "locale": "en",
    "account_owner": false
  }
}
FieldDescription
associated_user.idThe person's account id. It is the same in every store.
associated_user.account_ownertrue when the person owns the store
associated_user_scopeThe token's scopes, after the person's permissions are applied

JWT Payload ​

json
{
  "iss": "https://my-store.youcan.store/admin",
  "dest": "https://my-store.youcan.store",
  "aud": "<YOUR_CLIENT_ID>",
  "sub": "<seller-user-id>",
  "str": "<store-id>",
  "sid": "<stable-session-id>",
  "jti": "<token-id>",
  "scope": "read-orders edit-orders",
  "iat": 1709000000,
  "nbf": 1709000000,
  "exp": 1709000060
}
FieldUse
issThe store's admin URL. Must be dest followed by /admin.
destThe store's YouCan URL (https://<slug>.youcan.store). A custom domain of the store is never used here.
audMust match your Client ID
subThe id of the seller who opened your app
strThe store ID
sidSession ID. Use it as your DB primary key.
scopeYour app's scopes, separated by spaces
nbfReject the token before this time.
expReject expired tokens. Session tokens are valid for 1 minute.

Full Flow Diagram ​


Persisting Sessions ​

sql
CREATE TABLE sessions (
  id                  TEXT PRIMARY KEY,  -- JWT `sid`
  store               TEXT NOT NULL,     -- store id (JWT `str`)
  access_token        TEXT               -- null until first exchange
);

On re-install or re-open ​

When the HMAC-verified launch URL is processed, always reset the access token:

js
async function handleSessionPost(sessionId, store) {
  const existing = await db.session.findOne({ id: sessionId });

  if (existing) {
    // Reset to force fresh token exchange (handles reinstalls)
  } else {
    await db.session.create({ id: sessionId, store });
  }
}

Re-authentication & Token Recovery ​

ScenarioAction
Seller opens embedded appqantra.sessionToken() JWT injected → server looks up session → reuses stored token
No access token in DBJWT present → server does token_exchange → persists token
YouCan API returns 401Clear access_token in DB. Qantra retries with fresh JWT from qantra.sessionToken().
App uninstalledapp.uninstalled webhook → clear all access tokens for the store