Skip to content

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

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. 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 TokenA long-lived bearer token for the YouCan Store Admin API.
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).

Embedded vs External Apps

When a seller opens your app, the Seller Area detects the app type and builds a different authenticated URL:

EmbeddedExternal
Opens iniframe inside Seller AreaNew browser tab
URL containssession=SID + embedded=1code=AUTH_CODE + embedded=0
Token sourceqantra.sessionToken() at runtime?code= from URL
Exchange typetoken_exchange (JWT)authorization_code (code)
Requires QantraYesNo

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=SIG

The 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=SIG

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

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:

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.

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

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

Exchanging the session token

js
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 }
}

JWT Payload

json
{
  "iss": "https://api.youcan.shop",
  "aud": "<YOUR_CLIENT_ID>",
  "str": "my-store",
  "sid": "<stable-session-id>",
  "sub": "<seller-user-id>",
  "iat": 1709000000,
  "exp": 1709086400
}
FieldUse
strStore slug — identify the store
sidSession ID — use as your DB primary key
audMust match your Client ID
issMust be https://api.youcan.shop
expReject 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.

js
// 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');
});

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:

PathGrant TypeExchanged Credential
Embedded (A)token_exchangeJWT from qantra.sessionToken()
External (B)authorization_code?code= from URL

Both return:

json
{ "access_token": "...", "expires_in": 86400 }

Full Flow Diagrams

Path A — Embedded App

Path B — External App


Persisting Sessions

sql
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
);

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)
    await db.session.update({ id: sessionId, accessToken: null, registeredWebhooks: null });
  } 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