App Authentication
YouCan apps come in two types: embedded (loaded in an iframe inside the Seller Area) and external (opened in a new browser tab). The authentication model differs significantly between the two, but both ultimately produce a store access token your server uses to call the YouCan API.
TIP
If you create your app using the Nuxt Starter Template via the YouCan CLI, the entire embedded app authentication flow (including Qantra, JWT exchange, and session persistence) is already fully implemented for you.
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. This is what is exchanged for an access token. For embedded apps it is obtained at runtime from the Seller Area via Qantra — it is not in the URL. |
Authorization Code (code) | A one-time code in the URL for external apps. Directly exchangeable for an access token. |
| Access Token | A long-lived bearer token for the YouCan Store Admin API. |
| 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). |
Embedded vs External Apps
When a seller opens your app, the Seller Area detects the app type and builds a different authenticated URL:
| Embedded | External | |
|---|---|---|
| Opens in | iframe inside Seller Area | New browser tab |
| URL contains | session=SID + embedded=1 | code=AUTH_CODE + embedded=0 |
| Token source | qantra.sessionToken() at runtime | ?code= from URL |
| Exchange type | token_exchange (JWT) | authorization_code (code) |
| Requires Qantra | Yes | No |
The Launch URL
In both cases, YouCan signs all query parameters with an HMAC before redirecting the seller:
hmac = HMAC-SHA256(full_query_string_without_hmac, client_secret)Embedded app URL
https://your-app.com/?timestamp=...&session=SID&store=my-store&seller=ID&locale=en&embedded=1&hmac=SIGThe session param is only a DB lookup key. The actual session JWT is fetched at runtime via qantra.sessionToken().
External app URL
https://your-app.com/?timestamp=...&code=AUTH_CODE&state=STATE&store=my-store&seller=ID&locale=en&embedded=0&hmac=SIGStep 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
Path A — Embedded App
Overview
Your app renders inside an iframe. There is no code in the URL. The frontend calls qantra.sessionToken() to obtain a session JWT from the parent Seller Area at runtime.
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.
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);
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
Exchanging the session token
async function exchangeSessionToken(sessionToken) {
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: 'token_exchange',
client_id: process.env.YOUCAN_API_KEY,
client_secret: process.env.YOUCAN_API_SECRET,
session_token: sessionToken,
}),
});
return res.json(); // { access_token, expires_in }
}2
3
4
5
6
7
8
9
10
11
12
13
14
JWT Payload
{
"iss": "https://api.youcan.shop",
"aud": "<YOUR_CLIENT_ID>",
"str": "my-store",
"sid": "<stable-session-id>",
"sub": "<seller-user-id>",
"iat": 1709000000,
"exp": 1709086400
}2
3
4
5
6
7
8
9
| Field | Use |
|---|---|
str | Store slug — identify the store |
sid | Session ID — use as your DB primary key |
aud | Must match your Client ID |
iss | Must be https://api.youcan.shop |
exp | Reject expired tokens |
Path B — External App
There is no Qantra involved. Exchange the ?code= from the URL for an access token server-to-server before rendering the page.
// Express route handler
app.get('/', async (req, res) => {
const { code, store, hmac } = req.query;
if (!verifyHmac(req.url.split('?')[1], process.env.YOUCAN_API_SECRET)) {
return res.status(403).send('Forbidden');
}
const tokenRes = await fetch('https://api.youcan.shop/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: process.env.YOUCAN_API_KEY,
client_secret: process.env.YOUCAN_API_SECRET,
code,
}),
});
const { access_token } = await tokenRes.json();
await db.session.upsert({ store, accessToken: access_token });
res.redirect('/dashboard');
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
WARNING
The code is one-time use and short-lived. Exchange it immediately — never store it.
Token Exchange Summary
Both paths do a server-to-server exchange — only the grant_type and credential differ:
| Path | Grant Type | Exchanged Credential |
|---|---|---|
| Embedded (A) | token_exchange | JWT from qantra.sessionToken() |
| External (B) | authorization_code | ?code= from URL |
Both return:
{ "access_token": "...", "expires_in": 86400 }Full Flow Diagrams
Path A — Embedded App
Path B — External App
Persisting Sessions
Recommended schema
CREATE TABLE sessions (
id TEXT PRIMARY KEY, -- JWT `sid` (embedded) or derived hash (external)
store TEXT NOT NULL, -- store slug
access_token TEXT, -- null until first exchange
registered_webhooks TEXT -- for webhook state tracking
);2
3
4
5
6
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)
await db.session.update({ id: sessionId, accessToken: null, registeredWebhooks: null });
} else {
await db.session.create({ id: sessionId, store });
}
}2
3
4
5
6
7
8
9
10
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 |