Embedded App Authentication
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
| Term | Description |
|---|---|
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 Token | A 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 Token | A bearer token for the YouCan Store Admin API, offline or online. See Token types. |
| HMAC | A SHA-256 signature appended to the launch URL, proving it came from YouCan. |
| Qantra | YouCan'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=SIGThe 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.
- Take all query params from the URL except
hmac. - Build a query string (in the same order as received).
- Compute
HMAC-SHA256(query_string, CLIENT_SECRET). - Compare using constant-time comparison.
Warning
Never use == or === to compare signatures. Use constant-time comparison to prevent timing attacks.
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);
}2
3
4
5
6
7
8
9
10
11
12
13
14
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:
Authorization: Bearer <live session JWT>
X-Requested-With: XMLHttpRequest2
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)
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();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Token exchange request
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 }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| Parameter | Value |
|---|---|
grant_type | urn:ietf:params:oauth:grant-type:token-exchange |
subject_token | The session token from qantra.sessionToken() |
subject_token_type | urn:ietf:params:oauth:token-type:id_token |
requested_token_type | urn: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
| Offline | Online | |
|---|---|---|
| Tied to | The store | The person who opened your app |
| Lifetime | No expiry | 24 hours, no refresh token |
| Scopes | Your app's scopes | Your app's scopes, cut down to the person's store permissions |
| Use | Webhooks, background jobs, syncs | Requests 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:
{
"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
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
| Field | Description |
|---|---|
associated_user.id | The person's account id. It is the same in every store. |
associated_user.account_owner | true when the person owns the store |
associated_user_scope | The token's scopes, after the person's permissions are applied |
JWT Payload
{
"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
}2
3
4
5
6
7
8
9
10
11
12
13
| Field | Use |
|---|---|
iss | The store's admin URL. Must be dest followed by /admin. |
dest | The store's YouCan URL (https://<slug>.youcan.store). A custom domain of the store is never used here. |
aud | Must match your Client ID |
sub | The id of the seller who opened your app |
str | The store ID |
sid | Session ID. Use it as your DB primary key. |
scope | Your app's scopes, separated by spaces |
nbf | Reject the token before this time. |
exp | Reject expired tokens. Session tokens are valid for 1 minute. |
Full Flow Diagram
Persisting Sessions
Recommended schema
CREATE TABLE sessions (
id TEXT PRIMARY KEY, -- JWT `sid`
store TEXT NOT NULL, -- store id (JWT `str`)
access_token TEXT -- null until first exchange
);2
3
4
5
On re-install or re-open
When the HMAC-verified launch URL is processed, always reset the access token:
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 });
}
}2
3
4
5
6
7
8
9
Re-authentication & Token Recovery
| Scenario | Action |
|---|---|
| Seller opens embedded app | qantra.sessionToken() JWT injected → server looks up session → reuses stored token |
| No access token in DB | JWT present → server does token_exchange → persists token |
YouCan API returns 401 | Clear access_token in DB. Qantra retries with fresh JWT from qantra.sessionToken(). |
| App uninstalled | app.uninstalled webhook → clear all access tokens for the store |