Skip to main content
This guide walks through implementing authentication in your Cloud App. By the end, your backend will be able to request access tokens from JTL, verify session tokens from the AppBridge, and make authenticated API calls on behalf of a merchant’s tenant. If you need a conceptual overview of how JTL authentication works across Cloud, OnPremise, and SCX, see the OAuth 2.0 Flow and API Keys & Tokens pages.

Authentication Tokens and Their Roles

Cloud Apps use two tokens that work together: an access token and a session token.
TokenAnswers the questionWhere it comes from
Access tokenIs this app authorized to call JTL APIs?Your backend, by exchanging client credentials at the token endpoint
Session tokenWho is making this request? (which tenant, which user)Your frontend, via AppBridge: appBridge.method.call('getSessionToken')
The access token authorizes your app to call JTL’s tenant-specific APIs. The session token identifies which merchant the request is for. Most JTL Cloud API requests need both: the access token goes in the Authorization header, and the tenant ID (read from the verified session token) goes in the X-Tenant-ID header.

How They Fit Together

  1. Your frontend asks AppBridge for a session token.
  2. Your frontend sends the session token to your backend.
  3. Your backend verifies the session token and reads the tenantId from its payload.
  4. Your backend separately fetches an access token using its client credentials.
  5. Your backend calls the JTL Cloud API with the access token in Authorization: Bearer and the tenant ID in X-Tenant-ID.
Headless apps, cron jobs, and background sync only need the access token, since there is no user making a request. Apps with a UI in the App Shell need both.

Client Credentials: Getting an Access Token

Your backend authenticates with JTL’s Identity Provider using the CLIENT_ID and CLIENT_SECRET you received when registering your app in the Partner Portal.

Implementation

// lib/jtl-auth.ts

const AUTH_ENDPOINT = "https://auth.jtl-cloud.com/oauth2/token";
const API_BASE_URL = "https://api.jtl-cloud.com";

export async function getAccessToken(): Promise<string> {
  const clientId = process.env.CLIENT_ID;
  const clientSecret = process.env.CLIENT_SECRET;

  if (!clientId || !clientSecret) {
    throw new Error(
      "CLIENT_ID and CLIENT_SECRET must be defined in environment variables"
    );
  }

  const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString(
    "base64"
  );

  const response = await fetch(AUTH_ENDPOINT, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Basic ${credentials}`,
    },
    body: new URLSearchParams({
      grant_type: "client_credentials",
    }),
  });

  if (!response.ok) {
    const error = await response.json().catch(() => null);
    throw new Error(
      `Token request failed (${response.status}): ${error?.error || "unknown"}`
    );
  }

  const data = await response.json();
  return data.access_token;
}

export { API_BASE_URL };
What this does: Encodes your client credentials as Base64, sends them to JTL’s auth endpoint with the client_credentials grant type, and returns a JWT access token. This token authenticates your backend for API calls.

Token Response

A successful request returns:
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "expires_in": 3599,
  "scope": "",
  "token_type": "bearer"
}
FieldDescription
access_tokenJWT used in the Authorization: Bearer header for API calls
expires_inToken lifetime in seconds (~1 hour)
token_typeAlways bearer

Caching Tokens

Access tokens are valid for approximately one hour. Requesting a new token on every API call adds latency and unnecessary load on the auth server. Cache the token and refresh it before it expires.
// lib/token-cache.ts
 
let cachedToken: string | null = null;
let tokenExpiresAt = 0;
 
export async function getCachedAccessToken(): Promise<string> {
  const now = Date.now();
  const bufferMs = 60_000; // refresh 60 seconds before expiry
 
  if (cachedToken && now < tokenExpiresAt - bufferMs) {
    return cachedToken;
  }
 
  const token = await getAccessToken();
 
  // Decode the JWT to read the expiry (without verifying, since we just received it)
  const payload = JSON.parse(
    Buffer.from(token.split(".")[1], "base64").toString()
  );
 
  cachedToken = token;
  tokenExpiresAt = payload.exp * 1000;
 
  return cachedToken;
}
What this does: Stores the access token in memory and reuses it until 60 seconds before expiry. When the buffer is reached, it fetches a fresh token. This prevents both unnecessary auth requests and failures from expired tokens mid-request.
This in-memory cache works for single-instance servers. If you’re running multiple instances (e.g., behind a load balancer), use a shared cache like Redis instead.

Session Tokens: Verifying the Frontend User

When your app runs inside the App Shell, the frontend gets a session token from the AppBridge. This token identifies who the user is and which tenant (merchant) they belong to. Your backend must verify this token before trusting it.

How it Works

  1. Your frontend calls appBridge.method.call('getSessionToken') to get a session token from the App Shell
  2. The frontend sends this token to your backend (typically in a POST body or header)
  3. Your backend fetches JTL’s public keys (JWKS) and uses them to verify the token’s signature
  4. The verified payload contains the tenantId, userId, and tenantSlug

Session Token Payload

A decoded session token contains:
{
  "exp": 1700000000,
  "userId": "user-abc-123",
  "tenantId": "tenant-xyz-789",
  "tenantSlug": "my-store",
  "kid": "key-id-001"
}
FieldDescription
expExpiration timestamp (Unix seconds)
userIdThe JTL user who is currently logged in
tenantIdThe merchant’s tenant identifier. Use this in the X-Tenant-ID header for API calls.
tenantSlugHuman-readable tenant name
kidKey ID used to select the correct public key from JWKS

Implementation

// lib/verify-session.ts

import { importJWK, jwtVerify } from "jose";
import { getAccessToken, API_BASE_URL } from "./jtl-auth";

export interface SessionTokenPayload {
    exp: number;
    userId: string;
    tenantId: string;
    tenantSlug: string;
    kid: string;
}

export async function verifySessionToken(
    sessionToken: string
): Promise<SessionTokenPayload> {
    const accessToken = await getAccessToken();

    // Fetch JTL's public keys
    const response = await fetch(
    `${API_BASE_URL}/account/.well-known/jwks.json`,
    {
        headers: {
        Authorization: `Bearer ${accessToken}`,
        },
    }
    );

    if (!response.ok) {
    throw new Error(`Failed to fetch JWKS (${response.status})`);
    }

    const jwks = await response.json();

    // Select the correct key using the kid from the token header
    const tokenHeader = JSON.parse(
    Buffer.from(sessionToken.split(".")[0], "base64").toString()
    );
    const key = jwks.keys.find((k: { kid: string }) => k.kid === tokenHeader.kid)
    || jwks.keys[0];

    const publicKey = await importJWK(key, "EdDSA");
    const { payload } = await jwtVerify(sessionToken, publicKey);

    return payload as unknown as SessionTokenPayload;
}
What this does: Fetches JTL’s public keys from the JWKS endpoint (authenticated with your access token), selects the correct key using the kid from the session token header, and verifies the token’s signature. The returned payload tells you which user and tenant the request belongs to.
In production, cache the JWKS response. Public keys change infrequently, so fetching them on every request adds unnecessary latency. Refresh the cache when verification fails with a key mismatch.

Wiring it Together: The connect-tenant Route

The connect-tenant pattern ties both flows together. Your frontend gets a session token, sends it to your backend, and your backend verifies it and returns the tenant details.
// app/api/connect-tenant/route.ts (Next.js App Router)
 
import { NextRequest, NextResponse } from "next/server";
import { verifySessionToken } from "@/lib/verify-session";
 
export async function POST(request: NextRequest) {
  try {
    const { sessionToken } = await request.json();
 
    if (!sessionToken) {
      return NextResponse.json(
        { error: "Session token is required" },
        { status: 400 }
      );
    }
 
    const payload = await verifySessionToken(sessionToken);
 
    // In production, store the tenant connection in your database here
    console.log("Tenant connected:", payload);
 
    return NextResponse.json({
      success: true,
      tenantId: payload.tenantId,
      userId: payload.userId,
      tenantSlug: payload.tenantSlug,
    });
  } catch (error) {
    console.error("Connection failed:", error);
    return NextResponse.json(
      { error: "Failed to verify session token" },
      { status: 401 }
    );
  }
}
What this does: Receives the session token from your frontend, verifies it using JWKS, and returns the tenant details. In a production app, this is where you would store the tenant connection in your database so you can associate future API calls with the correct merchant.

Calling from the Frontend

Your frontend sends the session token to this route after the AppBridge initializes:
const sessionToken = await appBridge.method.call("getSessionToken");
 
const response = await fetch("/api/connect-tenant", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ sessionToken }),
});
 
const { tenantId } = await response.json();
What this does: Gets the session token from the App Shell via AppBridge, sends it to your backend for verification, and receives the verified tenant ID. Your frontend can then pass this tenant ID in subsequent API requests. For the full frontend integration pattern using React Context, see the AppBridge Provider in the From Scratch quickstart.

Tenant Mapping

When a merchant installs your app, you need to store a record linking their JTL tenant ID to your app’s internal state. Without this mapping, your backend has no way to associate future requests or background jobs with the correct merchant. In-memory storage works in development but is wiped on every restart and does not survive multiple server instances. Use a persistent store from the start.

What to Store

At minimum, persist the following on install:
FieldWhere it comes fromWhy you need it
tenantIdVerified session token payloadPrimary key, identifies the merchant
tenantSlugVerified session token payloadHuman-readable identifier, useful for logs and support
installedAtYour server timestampAudit trail, debugging
installedByUserIdVerified session token payloadWho installed the app, useful for support
If your app has its own user or account model, link the tenantId to your internal record.

When to Write

Write the record in your /api/connect-tenant handler, after you verify the session token and before you return success to the frontend. Use an upsert rather than an insert: the same merchant may reinstall your app, and a duplicate-key error on reinstall is a poor experience. A minimal PostgreSQL schema:
CREATE TABLE jtl_tenants (
  tenant_id      UUID PRIMARY KEY,
  tenant_slug    TEXT NOT NULL,
  installed_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  installed_by   UUID NOT NULL,
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Then upsert on install:
import { Pool } from "pg";
import { verifySessionToken } from "@/lib/verify-session";

const pool = new Pool();

export async function POST(request: Request) {
  const { sessionToken } = await request.json();
  const payload = await verifySessionToken(sessionToken);

  await pool.query(
    `INSERT INTO jtl_tenants (tenant_id, tenant_slug, installed_by)
     VALUES ($1, $2, $3)
     ON CONFLICT (tenant_id) DO UPDATE
       SET tenant_slug = EXCLUDED.tenant_slug,
           installed_by = EXCLUDED.installed_by,
           updated_at = NOW()`,
    [payload.tenantId, payload.tenantSlug, payload.userId]
  );

  return Response.json({ tenantId: payload.tenantId });
}
What this does: Verifies the session token to get a trusted tenant ID, then writes (or updates) the mapping in your database. The ON CONFLICT clause handles reinstalls cleanly.

When to Read

On every incoming request from your frontend, extract the tenant ID from the verified session token and look up your internal record:
const payload = await verifySessionToken(sessionToken);
const result = await pool.query(
  "SELECT * FROM jtl_tenants WHERE tenant_id = $1",
  [payload.tenantId]
);

if (result.rowCount === 0) {
  return Response.json({ error: "Tenant not found" }, { status: 404 });
}

const tenant = result.rows[0];
A merchant who uninstalls and reinstalls should invalidate any cached state your app holds for that tenant.

What Not to Do

A few anti-patterns cause most tenant-mapping bugs in production. Avoid each of these from the start.
Don’tWhy
Store tenant mappings only in memoryWiped on every restart, does not survive multiple instances
Trust a tenantId sent directly from the frontendA browser can send any value. Always extract the tenant ID from a server-verified session token
Store the session token itselfSession tokens expire. Store the tenantId they prove, not the token
Assume tenant IDs are sequential or predictableThey are UUIDs. Treat them as opaque identifiers

Token Lifecycle

Understanding when tokens expire and how to handle expiry prevents intermittent auth failures in production.

Access Tokens

Access tokens from the client credentials flow expire after approximately one hour (3599 seconds). Your backend should cache and reuse the token, refreshing it before expiry. See the token caching example above. If an API call returns 401 Unauthorized, clear your cached token and request a new one before retrying:
async function callApiWithRetry(url: string, tenantId: string) {
  let token = await getCachedAccessToken();
 
  let response = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      "X-Tenant-Id": tenantId,
    },
  });
 
  if (response.status === 401) {
    // Token may have expired, clear cache and retry once
    cachedToken = null;
    token = await getCachedAccessToken();
 
    response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${token}`,
        "X-Tenant-Id": tenantId,
      },
    });
  }
 
  return response;
}
What this does: Attempts the API call with the cached token. If the server returns 401, it clears the cache, gets a fresh token, and retries once. This handles the edge case where a token expires between the cache check and the API call.

Session Tokens

Session tokens from the AppBridge are short-lived. If your frontend holds a session token too long, verification will fail on the backend. Request a fresh session token before each backend call, or at minimum before operations that require verified identity:
// Get a fresh token before each sensitive operation
const freshToken = await appBridge.method.call("getSessionToken");

Common Auth Errors

These are the most frequent authentication issues and how to resolve them.
Your CLIENT_ID or CLIENT_SECRET is incorrect. Verify both values in your .env file. Check for extra whitespace, missing characters, or swapped values. If you’ve lost your secret, regenerate credentials by creating a new app in the Partner Portal.
Your access token has expired. If you’re caching tokens, make sure you refresh before the expires_in window closes. The token caching example refreshes 60 seconds before expiry to prevent this.
The session token from AppBridge could not be verified.Common causes: the JWKS endpoint returned an error (check your access token), the session token has expired (request a fresh one from AppBridge), or the kid in the token header doesn’t match any key in the JWKS response (refresh your cached JWKS).
The JWKS endpoint requires a valid access token in the Authorization header. Make sure you’re passing Bearer <access_token>, not the session token or client credentials. If the access token itself is expired, refresh it first.

What’s Next

Using Platform APIs

Call the JTL Cloud and JTL-Wawi REST and GraphQL APIs with your authenticated tokens.

App Shell & UI Integration

Reference for the manifest, AppBridge API, and Platform UI components.

Best Practices

Production patterns for token caching, error handling, and security.