> ## Documentation Index
> Fetch the complete documentation index at: https://developer.jtl-software.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication & Login

> 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](/guides/essentials/authentication/oauth2-flow) and [API Keys & Tokens](/guides/essentials/authentication/api-keys-tokens) pages.

## Authentication Tokens and Their Roles

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.

### 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`.

```mermaid theme={null}
sequenceDiagram
    participant FE as Your Frontend
    participant BE as Your Backend
    participant Bridge as AppBridge
    participant Auth as JTL Auth
    participant API as JTL Cloud API

    FE->>Bridge: getSessionToken()
    Bridge-->>FE: Session token (identity)
    FE->>BE: Request (with session token)
    BE->>BE: Verify session token, extract tenantId
    BE->>Auth: Client credentials grant
    Auth-->>BE: Access token (authorization)
    BE->>API: Request + Bearer token + X-Tenant-ID
    API-->>BE: Data
    BE-->>FE: Response
```

## 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](https://partner.jtl-cloud.com/).

### Implementation

<CodeGroup>
  ```typescript TypeScript theme={null}
    // 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 };

  ```

  ```csharp C# theme={null}
    // JtlAuth.cs
    using System.Net.Http.Headers;
    using System.Text;
    using System.Text.Json;

    public static class JtlAuth
    {
        private const string AuthEndpoint = "https://auth.jtl-cloud.com/oauth2/token";
        public const string ApiBaseUrl = "https://api.jtl-cloud.com";

        private static readonly HttpClient HttpClient = new();

        public static async Task<string> GetAccessTokenAsync()
        {
            var clientId = Environment.GetEnvironmentVariable("CLIENT_ID");
            var clientSecret = Environment.GetEnvironmentVariable("CLIENT_SECRET");

            if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
                throw new InvalidOperationException(
                    "CLIENT_ID and CLIENT_SECRET must be defined in environment variables"
                );

            var credentials = Convert.ToBase64String(
                Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")
            );

            var request = new HttpRequestMessage(HttpMethod.Post, AuthEndpoint);
            request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
            request.Content = new FormUrlEncodedContent(new[]
            {
                new KeyValuePair<string, string>("grant_type", "client_credentials")
            });

            var response = await HttpClient.SendAsync(request);

            if (!response.IsSuccessStatusCode)
            {
                string? errorCode = null;
                try
                {
                    var errorBody = await response.Content.ReadAsStringAsync();
                    using var errorDoc = JsonDocument.Parse(errorBody);
                    errorCode = errorDoc.RootElement.GetProperty("error").GetString();
                }
                catch { /* ignore parse failures */ }

                throw new HttpRequestException(
                    $"Token request failed ({(int)response.StatusCode}): {errorCode ?? "unknown"}"
                );
            }

            var json = await response.Content.ReadAsStringAsync();
            using var doc = JsonDocument.Parse(json);

            return doc.RootElement.GetProperty("access_token").GetString()
                ?? throw new InvalidOperationException("Response did not contain an access_token");
        }
    }
  ```

  ```php PHP theme={null}
  <?php
  // src/Jtl/JtlAuth.php
  declare(strict_types=1);

  namespace App\Jtl;

  use GuzzleHttp\Client;
  use GuzzleHttp\Exception\RequestException;
  use RuntimeException;

  final class JtlAuth
  {
      private const AUTH_ENDPOINT = 'https://auth.jtl-cloud.com/oauth2/token';
      public const API_BASE_URL = 'https://api.jtl-cloud.com';

      private static ?Client $httpClient = null;

      public static function getAccessToken(): string
      {
          $clientId = getenv('CLIENT_ID') ?: null;
          $clientSecret = getenv('CLIENT_SECRET') ?: null;

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

          $credentials = base64_encode("{$clientId}:{$clientSecret}");

          try {
              $response = self::httpClient()->post(self::AUTH_ENDPOINT, [
                  'headers' => [
                      'Content-Type' => 'application/x-www-form-urlencoded',
                      'Authorization' => "Basic {$credentials}",
                  ],
                  'form_params' => [
                      'grant_type' => 'client_credentials',
                  ],
              ]);
          } catch (RequestException $e) {
              $status = $e->getResponse()?->getStatusCode() ?? 0;
              $errorCode = 'unknown';

              if ($e->hasResponse()) {
                  $body = (string) $e->getResponse()->getBody();
                  $decoded = json_decode($body, true);
                  $errorCode = $decoded['error'] ?? 'unknown';
              }

              throw new RuntimeException("Token request failed ({$status}): {$errorCode}");
          }

          $data = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);

          return $data['access_token'];
      }

      private static function httpClient(): Client
      {
          return self::$httpClient ??= new Client();
      }
  }
  ```

  ```bash cURL theme={null}
    curl -X POST https://auth.jtl-cloud.com/oauth2/token \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -H "Authorization: Basic $(echo -n 'CLIENT_ID:CLIENT_SECRET' | base64)" \
      -d "grant_type=client_credentials"
  ```
</CodeGroup>

**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:

```json theme={null}
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "expires_in": 86399,
  "scope": "",
  "token_type": "bearer"
}
```

| Field          | Description                                                  |
| -------------- | ------------------------------------------------------------ |
| `access_token` | JWT used in the `Authorization: Bearer` header for API calls |
| `expires_in`   | Token lifetime in seconds (\~24 hours)                       |
| `token_type`   | Always `bearer`                                              |

### Caching and Refreshing Tokens

Access tokens are valid for approximately 24 hours. 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.

<CodeGroup>
  ```typescript TypeScript  theme={null}
  // lib/token-cache.ts
  let cachedToken: string | null = null;
  let tokenExpiresAt = 0;
  let inflightRequest: Promise<string> | null = null;

  export async function getCachedAccessToken(): Promise<string> {
    const now = Date.now();
    const bufferMs = 60_000;

    if (cachedToken && now < tokenExpiresAt - bufferMs) {
      return cachedToken;
    }

    if (inflightRequest) {
      return inflightRequest;
    }

    inflightRequest = (async () => {
      try {
        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], "base64url").toString()
        );

        cachedToken = token;
        tokenExpiresAt = payload.exp * 1000;

        return cachedToken!;
      } finally {
        inflightRequest = null;
      }
    })();

    return inflightRequest;

  }

  export function clearTokenCache(): void {
    cachedToken = null;
    tokenExpiresAt = 0;
  }
  ```

  ```csharp C# theme={null}
  // TokenCache.cs
  using System.Text.Json;

  public static class TokenCache
  {
      private static string? _cachedToken = null;
      private static long _tokenExpiresAt = 0;
      private static readonly SemaphoreSlim _lock = new(1, 1);

      public static async Task<string> GetCachedAccessTokenAsync()
      {
          var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
          const long bufferMs = 60_000;

          if (_cachedToken != null && now < _tokenExpiresAt - bufferMs)
              return _cachedToken;

          await _lock.WaitAsync();
          try
          {
              now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
              if (_cachedToken != null && now < _tokenExpiresAt - bufferMs)
                  return _cachedToken;

              var token = await JtlAuth.GetAccessTokenAsync();

              // Decode the JWT to read the expiry (without verifying, since we just received it)
              var payloadBase64 = token.Split('.')[1];
              var paddedPayload = payloadBase64.PadRight(
                  payloadBase64.Length + (4 - payloadBase64.Length % 4) % 4, '='
              );
              var payloadJson = System.Text.Encoding.UTF8.GetString(
                  Convert.FromBase64String(paddedPayload)
              );
              using var doc = JsonDocument.Parse(payloadJson);
              var exp = doc.RootElement.GetProperty("exp").GetInt64();

              _cachedToken = token;
              _tokenExpiresAt = exp * 1000;

              return _cachedToken;
          }
          finally
          {
              _lock.Release();
          }
      }

      public static void ClearCache()
      {
          _cachedToken = null;
          _tokenExpiresAt = 0;
      }
  }
  ```

  ```php PHP theme={null}
  <?php
  // src/Jtl/TokenCache.php
  declare(strict_types=1);

  namespace App\Jtl;

  use RuntimeException;

  final class TokenCache
  {
      private static ?string $cachedToken = null;
      private static int $tokenExpiresAt = 0;

      public static function getCachedAccessToken(): string
      {
          $now = (int) (microtime(true) * 1000);
          $bufferMs = 60_000;

          if (self::$cachedToken !== null && $now < self::$tokenExpiresAt - $bufferMs) {
              return self::$cachedToken;
          }

          $token = JtlAuth::getAccessToken();

          // Decode the JWT to read the expiry (without verifying, since we just received it)
          $parts = explode('.', $token);
          if (count($parts) !== 3) {
              throw new RuntimeException('Invalid JWT format. Expected 3 dot-separated parts');
          }

          $payload = json_decode(
              self::base64UrlDecode($parts[1]),
              true,
              flags: JSON_THROW_ON_ERROR
          );

          self::$cachedToken = $token;
          self::$tokenExpiresAt = $payload['exp'] * 1000;

          return self::$cachedToken;
      }

      public static function clearCache(): void
      {
          self::$cachedToken = null;
          self::$tokenExpiresAt = 0;
      }

      private static function base64UrlDecode(string $input): string
      {
          $padded = strtr($input, '-_', '+/');
          $padded .= str_repeat('=', (4 - strlen($padded) % 4) % 4);
          return base64_decode($padded);
      }
  }
  ```
</CodeGroup>

**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.

<Note>
  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.
</Note>

## 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 (through the 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:

```json theme={null}
{
	"exp": 1700000000,
	"userId": "user-abc-123",
	"tenantId": "tenant-xyz-789",
	"tenantSlug": "my-store"
}
```

| Field        | Description                                                                           |
| ------------ | ------------------------------------------------------------------------------------- |
| `exp`        | Expiration timestamp (Unix seconds)                                                   |
| `userId`     | The JTL user who is currently logged in                                               |
| `tenantId`   | The merchant's tenant identifier. Use this in the `X-Tenant-ID` header for API calls. |
| `tenantSlug` | Human-readable tenant name                                                            |

### Implementation

<CodeGroup>
  ```typescript TypeScript theme={null}
  // 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;
  }

  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 signing key by its JWK properties (use=sig, alg=EdDSA)
      const signingKey = jwks.keys.find(
          (k: { use?: string; alg?: string }) =>
              k.use === "sig" && k.alg === "EdDSA"
      );
   
      if (!signingKey) {
          throw new Error("No EdDSA signing key found in JWKS");
      }
   
      const publicKey = await importJWK(signingKey, "EdDSA");
   
      const { payload } = await jwtVerify(sessionToken, publicKey);

      return payload as unknown as SessionTokenPayload;
  }
  ```

  ```csharp C# theme={null}
  // VerifySession.cs
  using System.Net.Http.Headers;
  using System.Text;
  using System.Text.Json;
  using NSec.Cryptography;
   
  public record SessionTokenPayload(
      long Exp,
      string UserId,
      string TenantId,
      string? TenantSlug
  );
   
  public static class VerifySession
  {
      private static readonly HttpClient HttpClient = new();
   
      public static async Task<SessionTokenPayload> VerifySessionTokenAsync(string sessionToken)
      {
          var accessToken = await JtlAuth.GetAccessTokenAsync();
   
          var request = new HttpRequestMessage(
              HttpMethod.Get,
              $"{JtlAuth.ApiBaseUrl}/account/.well-known/jwks.json"
          );
          request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
   
          var response = await HttpClient.SendAsync(request);
   
          if (!response.IsSuccessStatusCode)
              throw new HttpRequestException($"Failed to fetch JWKS ({(int)response.StatusCode})");
   
          var jwksJson = await response.Content.ReadAsStringAsync();
          var jwks = JsonSerializer.Deserialize<JsonElement>(jwksJson);
   
          // Select the signing key by its JWK properties (use=sig, alg=EdDSA)
          JsonElement? signingKey = null;
          foreach (var k in jwks.GetProperty("keys").EnumerateArray())
          {
              var use = k.TryGetProperty("use", out var u) ? u.GetString() : null;
              var alg = k.TryGetProperty("alg", out var a) ? a.GetString() : null;
              if (use == "sig" && alg == "EdDSA")
              {
                  signingKey = k;
                  break;
              }
          }
   
          if (signingKey is null)
              throw new InvalidOperationException("No EdDSA signing key found in JWKS");
   
          // JTL session tokens are signed with Ed25519 (OKP)
          var publicKeyBytes = Base64UrlDecode(signingKey.Value.GetProperty("x").GetString()!);
   
          return VerifyAndDecode(sessionToken, publicKeyBytes);
      }
   
      private static SessionTokenPayload VerifyAndDecode(string token, byte[] publicKeyBytes)
      {
          var parts = token.Split('.');
          if (parts.Length != 3)
              throw new ArgumentException("Invalid JWT format. Expected 3 dot-separated parts.");
   
          var signedData = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
          var signature = Base64UrlDecode(parts[2]);
   
          var algorithm = SignatureAlgorithm.Ed25519;
          var publicKey = PublicKey.Import(algorithm, publicKeyBytes, KeyBlobFormat.RawPublicKey);
   
          if (!algorithm.Verify(publicKey, signedData, signature))
              throw new UnauthorizedAccessException("Invalid token signature.");
   
          var payloadJson = Encoding.UTF8.GetString(Base64UrlDecode(parts[1]));
          var payload = JsonSerializer.Deserialize<JsonElement>(payloadJson);
   
          var exp = payload.GetProperty("exp").GetInt64();
          if (DateTimeOffset.FromUnixTimeSeconds(exp) < DateTimeOffset.UtcNow)
              throw new UnauthorizedAccessException("Token has expired.");
   
          return new SessionTokenPayload(
              Exp: exp,
              UserId: payload.GetProperty("userId").GetString()!,
              TenantId: payload.GetProperty("tenantId").GetString()!,
              TenantSlug: payload.TryGetProperty("tenantSlug", out var slug) ? slug.GetString() : null
          );
      }
   
      private static byte[] Base64UrlDecode(string input)
      {
          var padded = input.Replace('-', '+').Replace('_', '/');
          padded += (padded.Length % 4) switch
          {
              2 => "==",
              3 => "=",
              _ => ""
          };
          return Convert.FromBase64String(padded);
      }
  }
  ```

  ```php PHP theme={null}
  <?php
  // src/Jtl/VerifySession.php
  declare(strict_types=1);
   
  namespace App\Jtl;
   
  use GuzzleHttp\Client;
  use RuntimeException;
  use UnexpectedValueException;
   
  final readonly class SessionTokenPayload
  {
      public function __construct(
          public int $exp,
          public string $userId,
          public string $tenantId,
          public ?string $tenantSlug,
      ) {}
  }
   
  final class VerifySession
  {
      private static ?Client $httpClient = null;
   
      public static function verifySessionToken(string $sessionToken): SessionTokenPayload
      {
          $accessToken = JtlAuth::getAccessToken();
   
          // Fetch JTL's public keys
          $response = self::httpClient()->get(
              JtlAuth::API_BASE_URL . '/account/.well-known/jwks.json',
              ['headers' => ['Authorization' => "Bearer {$accessToken}"]]
          );
   
          if ($response->getStatusCode() !== 200) {
              throw new RuntimeException("Failed to fetch JWKS ({$response->getStatusCode()})");
          }
   
          $jwks = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
   
          // Split the JWT into its three parts
          $parts = explode('.', $sessionToken);
          if (count($parts) !== 3) {
              throw new UnexpectedValueException('Invalid JWT format. Expected 3 dot-separated parts');
          }
          [$headerB64, $payloadB64, $signatureB64] = $parts;
   
          // Select the signing key by its JWK properties (use=sig, alg=EdDSA)
          $signingKey = null;
          foreach ($jwks['keys'] as $k) {
              if (($k['use'] ?? null) === 'sig' && ($k['alg'] ?? null) === 'EdDSA') {
                  $signingKey = $k;
                  break;
              }
          }
   
          if ($signingKey === null) {
              throw new RuntimeException('No EdDSA signing key found in JWKS');
          }
   
          // JTL session tokens are signed with Ed25519 (OKP). The raw public key
          // sits in the `x` parameter of the JWK as base64url-encoded bytes.
          $publicKey = self::base64UrlDecode($signingKey['x']);
          $signedData = "{$headerB64}.{$payloadB64}";
          $signature = self::base64UrlDecode($signatureB64);
   
          $isValid = sodium_crypto_sign_verify_detached($signature, $signedData, $publicKey);
          if (!$isValid) {
              throw new UnexpectedValueException('Invalid token signature');
          }
   
          $payload = json_decode(self::base64UrlDecode($payloadB64), true, flags: JSON_THROW_ON_ERROR);
   
          if (($payload['exp'] ?? 0) < time()) {
              throw new UnexpectedValueException('Token has expired');
          }
   
          return new SessionTokenPayload(
              exp: $payload['exp'],
              userId: $payload['userId'],
              tenantId: $payload['tenantId'],
              tenantSlug: $payload['tenantSlug'] ?? null,
          );
      }
   
      private static function httpClient(): Client
      {
          return self::$httpClient ??= new Client();
      }
   
      private static function base64UrlDecode(string $input): string
      {
          $padded = strtr($input, '-_', '+/');
          $padded .= str_repeat('=', (4 - strlen($padded) % 4) % 4);
          return base64_decode($padded);
      }
  }
  ```
</CodeGroup>

**What this does:** Fetches JTL's public keys from the JWKS endpoint (authenticated with your access token) and verifies the token's signature. The returned payload tells you which user and tenant the request belongs to.

<Tip>
  In production, cache the JWKS response. Public keys change infrequently, so fetching them on every request adds unnecessary latency.
</Tip>

***

## Wiring it Together: The `connect-tenant` Route

The connect-tenant pattern ties both flows together. Your frontend gets a session token, sends it as a header (`X-Session-ID`) to your backend, and your backend verifies it and returns the tenant details.

<CodeGroup>
  ```typescript TypeScript theme={null}
  import express, { Request, Response } from 'express';
  import { verifySessionToken } from '../lib/verify-session';

  const app = express();

  app.get('/api/connect-tenant', async (req: Request, res: Response) => {
      const sessionToken = req.headers['x-session-id'] as string;

      if (!sessionToken) {
          return res.status(400).json({ error: 'Session token is required' });
      }

      try {
          const payload = await verifySessionToken(sessionToken);

          return res.json({
              success: true,
              tenantId: payload.tenantId,
              userId: payload.userId,
              tenantSlug: payload.tenantSlug,
          });
      } catch (error) {
          return res.status(401).json({ error: 'Failed to verify session token' });
      }
  });
  ```

  ```csharp C#  theme={null}
  // ConnectTenantEndpoint.cs

  public static class ConnectTenantEndpoint
  {
      private static readonly HttpClient HttpClient = new();

      public static async Task<IResult> HandleAsync(HttpRequest request)
      {
          try
          {
              var sessionToken = request.Headers["X-Session-ID"].FirstOrDefault();

              if (string.IsNullOrEmpty(sessionToken))
              {
                  return Results.Json(new { error = "Session token is required" }, statusCode: 400);
              }

              var payload = await VerifySession.VerifySessionTokenAsync(sessionToken);

              // In production, store the tenant connection in your database here

              return Results.Json(new
              {
                  success = true,
                  tenantId = payload.TenantId,
                  userId = payload.UserId,
                  tenantSlug = payload.TenantSlug,
              });
          }
          catch (Exception ex)
          {
              Console.Error.WriteLine($"Connection failed: {ex}");
              return Results.Json(new { error = "Failed to verify session token" }, statusCode: 401);
          }
      }
  }
  ```

  ```php PHP theme={null}
  <?php
  // src/Jtl/ConnectTenantController.php
  declare(strict_types=1);

  namespace App\Jtl;

  use Psr\Http\Message\ResponseInterface;
  use Psr\Http\Message\ServerRequestInterface;
  use Slim\Psr7\Response;
  use Throwable;

  final class ConnectTenantController
  {
      public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
      {
          $sessionToken = $request->getHeaderLine('X-Session-ID');

          if ($sessionToken === '') {
              return self::json($response, ['error' => 'Session token is required'], 400);
          }

          try {
              $payload = VerifySession::verifySessionToken($sessionToken);

              return self::json($response, [
                  'success' => true,
                  'tenantId' => $payload->tenantId,
                  'userId' => $payload->userId,
                  'tenantSlug' => $payload->tenantSlug,
              ]);
          } catch (Throwable) {
              return self::json($response, ['error' => 'Failed to verify session token'], 401);
          }
      }

      private static function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
      {
          $response->getBody()->write(json_encode($data, JSON_THROW_ON_ERROR));
          return $response->withHeader('Content-Type', 'application/json')->withStatus($status);
      }
  }
  ```
</CodeGroup>

**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:

```typescript theme={null}
const sessionToken = await appBridge.method.call("getSessionToken");

const response = await fetch("/api/connect-tenant", {
  method: "POST",
  headers: {
    "X-Session-ID": 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](/get-started/quick-start/from-scratch#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.

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:

| Field               | Where it comes from            | Why you need it                                        |
| ------------------- | ------------------------------ | ------------------------------------------------------ |
| `tenantId`          | Verified session token payload | Primary key, identifies the merchant                   |
| `tenantSlug`        | Verified session token payload | Human-readable identifier, useful for logs and support |
| `installedAt`       | Your server timestamp          | Audit trail, debugging                                 |
| `installedByUserId` | Verified session token payload | Who 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:

```sql theme={null}
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:

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { Request, Response } from 'express';
  import { Pool } from 'pg';
  import { verifySessionToken } from '../lib/verify-session';

  const pool = new Pool();

  app.get('/api/install', async (req: Request, res: Response) => {
      const sessionToken = req.headers['x-session-id'] as string;

      if (!sessionToken) {
          return res.status(400).json({ error: 'Session token is required' });
      }

      try {
          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 res.json({ tenantId: payload.tenantId });
      } catch (error) {
          return res.status(401).json({ error: 'Failed to verify session token' });
      }
  });

  ```

  ```csharp C# theme={null}
  using Microsoft.AspNetCore.Mvc;
  using Npgsql;

  public static class InstallEndpoint
  {
      private static readonly HttpClient HttpClient = new();
      private static readonly NpgsqlDataSource DataSource =
          NpgsqlDataSource.Create(Environment.GetEnvironmentVariable("DATABASE_URL")!);

      public static async Task<IResult> HandleAsync(HttpRequest request)
      {
          var sessionToken = request.Headers["X-Session-ID"].FirstOrDefault()
              ?? throw new InvalidOperationException("X-Session-ID header is required");

          var payload = await VerifySession.VerifySessionTokenAsync(sessionToken);

          await using var cmd = DataSource.CreateCommand(
              """
              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()
              """
          );
          cmd.Parameters.AddWithValue(payload.TenantId);
          cmd.Parameters.AddWithValue(payload.TenantSlug);
          cmd.Parameters.AddWithValue(payload.UserId);
          await cmd.ExecuteNonQueryAsync();

          return Results.Json(new { tenantId = payload.TenantId });
      }
  }
  ```

  ```php PHP theme={null}
  <?php
  // src/Jtl/InstallController.php
  declare(strict_types=1);

  namespace App\Jtl;

  use PDO;
  use Psr\Http\Message\ResponseInterface;
  use Psr\Http\Message\ServerRequestInterface;

  final class InstallController
  {
      public function __construct(private readonly PDO $pdo) {}

      public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
      {
          $sessionToken = $request->getHeaderLine('X-Session-ID');

          if ($sessionToken === '') {
              return self::json($response, ['error' => 'Session token is required'], 400);
          }

          $payload = VerifySession::verifySessionToken($sessionToken);

          $stmt = $this->pdo->prepare(
              'INSERT INTO jtl_tenants (tenant_id, tenant_slug, installed_by)
               VALUES (:tenant_id, :tenant_slug, :installed_by)
               ON CONFLICT (tenant_id) DO UPDATE
                   SET tenant_slug  = EXCLUDED.tenant_slug,
                       installed_by = EXCLUDED.installed_by,
                       updated_at   = NOW()'
          );

          $stmt->execute([
              ':tenant_id' => $payload->tenantId,
              ':tenant_slug' => $payload->tenantSlug,
              ':installed_by' => $payload->userId,
          ]);

          return self::json($response, ['tenantId' => $payload->tenantId]);
      }

      private static function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
      {
          $response->getBody()->write(json_encode($data, JSON_THROW_ON_ERROR));
          return $response->withHeader('Content-Type', 'application/json')->withStatus($status);
      }
  }
  ```
</CodeGroup>

**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:

<CodeGroup>
  ```typescript TypeScript theme={null}
    // Inside your route handler
    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 res.status(404).json({ error: 'Tenant not found' });
    }

    const tenant = result.rows[0];
  ```

  ```csharp C#  theme={null}
    // Inside your route handler
    var payload = await VerifySession.VerifySessionTokenAsync(sessionToken);

    await using var cmd = DataSource.CreateCommand(
        "SELECT * FROM jtl_tenants WHERE tenant_id = $1"
    );
    cmd.Parameters.AddWithValue(payload.TenantId);

    await using var reader = await cmd.ExecuteReaderAsync();

    if (!reader.HasRows)
        return Results.Json(new { error = "Tenant not found" }, statusCode: 404);

    await reader.ReadAsync();
    var tenant = new
    {
        TenantId = reader["tenant_id"],
        TenantSlug = reader["tenant_slug"],
        InstalledBy = reader["installed_by"],
        UpdatedAt = reader["updated_at"],
    };
  ```

  ```php PHP theme={null}
  // Inside your route handler ($request, $response, $pdo are in scope)
  $payload = VerifySession::verifySessionToken($sessionToken);

  $stmt = $pdo->prepare('SELECT * FROM jtl_tenants WHERE tenant_id = :tenant_id');
  $stmt->execute([':tenant_id' => $payload->tenantId]);
  $tenant = $stmt->fetch(PDO::FETCH_ASSOC);

  if ($tenant === false) {
      $response->getBody()->write(json_encode(['error' => 'Tenant not found']));
      return $response->withHeader('Content-Type', 'application/json')->withStatus(404);
  }
  ```
</CodeGroup>

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't                                              | Why                                                                                             |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Store tenant mappings only in memory               | Wiped on every restart, does not survive multiple instances                                     |
| Trust a `tenantId` sent directly from the frontend | A browser can send any value. Always extract the tenant ID from a server-verified session token |
| Store the session token itself                     | Session tokens expire. Store the `tenantId` they prove, not the token                           |
| Assume tenant IDs are sequential or predictable    | They 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 **24 hours** (86399 seconds). Your backend should cache and reuse the token, refreshing it before expiry. See the [token caching example](#caching-tokens) above.

If an API call returns `401 Unauthorized`, clear your cached token and request a new one before retrying:

<CodeGroup>
  ```typescript TypeScript theme={null}
  // lib/api-client.ts
  import { getCachedAccessToken, clearTokenCache } from './token-cache';

  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
      clearTokenCache();
      token = await getCachedAccessToken();

      response = await fetch(url, {
        headers: {
          Authorization: `Bearer ${token}`,
          'X-Tenant-ID': tenantId,
        },
      });
    }

    return response;
  }
  ```

  ```csharp C#  theme={null}
  // ApiClient.cs
  using System.Net.Http.Headers;

  public static class ApiClient
  {
      private static readonly HttpClient HttpClient = new();

      public static async Task<HttpResponseMessage> CallApiWithRetryAsync(string url, string tenantId)
      {
          var token = await TokenCache.GetCachedAccessTokenAsync();
          var response = await SendRequestAsync(url, tenantId, token);

          if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
          {
              // Token may have expired, clear cache and retry once
              TokenCache.ClearCache();
              token = await TokenCache.GetCachedAccessTokenAsync();
              response = await SendRequestAsync(url, tenantId, token);
          }

          return response;
      }

      private static async Task<HttpResponseMessage> SendRequestAsync(string url, string tenantId, string token)
      {
          var request = new HttpRequestMessage(HttpMethod.Get, url);
          request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
          request.Headers.Add("X-Tenant-ID", tenantId);
          return await HttpClient.SendAsync(request);
      }
  }
  ```

  ```php PHP theme={null}
  <?php
  // src/Jtl/ApiClient.php
  declare(strict_types=1);

  namespace App\Jtl;

  use GuzzleHttp\Client;
  use GuzzleHttp\Exception\RequestException;
  use GuzzleHttp\Psr7\Response;

  final class ApiClient
  {
    private static ?Client $httpClient = null;

    public static function callApiWithRetry(string $url, string $tenantId): Response
    {
        $token = TokenCache::getCachedAccessToken();
        $response = self::sendRequest($url, $tenantId, $token);

        if ($response->getStatusCode() === 401) {
            // Token may have expired, clear cache and retry once
            TokenCache::clearCache();
            $token = TokenCache::getCachedAccessToken();
            $response = self::sendRequest($url, $tenantId, $token);
        }

        return $response;
    }

    private static function sendRequest(string $url, string $tenantId, string $token): Response
    {
        try {
            return self::httpClient()->get($url, [
                'headers' => [
                    'Authorization' => "Bearer {$token}",
                    'X-Tenant-ID' => $tenantId,
                ],
                'http_errors' => false,
            ]);
        } catch (RequestException $e) {
            return $e->getResponse() ?? throw $e;
        }
    }

    private static function httpClient(): Client
    {
        return self::$httpClient ??= new Client();
    }
  }
  ```
</CodeGroup>

**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:

```typescript theme={null}
// Get a fresh token before each sensitive operation
const freshToken = await appBridge.method.call('getSessionToken');
```

## Common Authentication Errors

These are the most frequent authentication issues and how to resolve them.

<AccordionGroup>
  <Accordion title="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](https://partner.jtl-cloud.com).
  </Accordion>

  <Accordion title="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](#caching-tokens) refreshes 60 seconds before expiry to prevent this.
  </Accordion>

  <Accordion title="401 Unauthorized: session token verification failed">
    The session token from AppBridge could not be verified.

    Common causes: the JWKS endpoint returned an error (check your access token), or the session token has expired (request a fresh one from AppBridge).
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

***

## What's Next

<CardGroup cols={2}>
  <Card title="Using Platform APIs" icon="database" href="/guides/cloud-apps/using-platform-apis">
    Call the JTL Cloud and JTL-Wawi REST and GraphQL APIs with your authenticated tokens.
  </Card>

  <Card title="App Shell & UI Integration" icon="app-window" href="/guides/cloud-apps/app-shell-ui-integration">
    Reference for the manifest, AppBridge API, and Platform UI components.
  </Card>

  <Card title="Best Practices" icon="star" href="/guides/cloud-apps/best-practices">
    Production patterns for token caching, error handling, and security.
  </Card>
</CardGroup>
