Implement OAuth 2.0 client credentials and session token verification in your JTL Cloud App.
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.
Cloud Apps use two tokens that work together: an access token and a session token.
Token
Answers the question
Where it comes from
Access token
Is this app authorized to call JTL APIs?
Your backend, by exchanging client credentials at the token endpoint
Session token
Who 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.
Your frontend sends the session token to your backend.
Your backend verifies the session token and reads the tenantId from its payload.
Your backend separately fetches an access token using its client credentials.
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.
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.
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.
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.tslet 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.
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.
// lib/verify-session.tsimport { 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.
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.
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.
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.
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.
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 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 operationconst freshToken = await appBridge.method.call("getSessionToken");
These are the most frequent authentication issues and how to resolve them.
401 Unauthorized: invalid_client
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.
401 Unauthorized: expired token
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).
JWKS fetch fails with 401
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.