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

# App Token Authentication

> Verify the app token in your backend with JWKS, read the tenant from its claims, and map merchants to your own records.

An app token identifies the merchant using your app and the tenant they belong to. It is a JWT issued by the platform identity provider, signed with RS256, and bound to your app. This means a token issued for another app cannot be used with yours.

The way your app obtains the token depends on where it runs:

| Where your app runs  | How it obtains the token                                                                                                                   |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Inside the Cloud ERP | `appBridge.method.call('getAppToken')`                                                                                                     |
| On your own domain   | Browser sign-in with `@jtl-software/cloud-apps-auth`. See [Standalone Authentication](/cloud/guides/cloud-apps/standalone-authentication). |

## Before You Start

Token verification requires two values. The [CLI's](/cloud/get-started/quick-start/from-template#1-create-the-project-using-cloud-apps-cli) `npm run register` command writes these values to your backend `.env` file.

| Variable     | Value                                                                                                     |
| ------------ | --------------------------------------------------------------------------------------------------------- |
| `JTL_ISSUER` | The identity provider that issues app tokens, `https://id.jtl-cloud.com` in production                    |
| `JTL_APP_ID` | Your app's ID, a GUID. Also shown in the [Partner Portal](https://partner.jtl-cloud.com/) under your app. |

## Understand the Token Claims

Once decoded, an app token contains claims that identify the token, the merchant, and the tenant:

```json theme={null}
{
  "aud": [
    "5aa3624f-2236-4e5b-b7e5-7091be7764f0"
  ],
  "client_id": "389707023228376722",
  "exp": 1788865347,
  "iat": 1788861747,
  "iss": "https://id.jtl-cloud.com",
  "jti": "V2_389845033982945061-at_389845033983010597",
  "nbf": 1788861747,
  "sub": "0292bace-2568-4722-a6fc-50023ca1650a",
  "urn:jtl:kundencenter_id": "1348883",
  "urn:jtl:tenant_id": "73bd3d84-f7e3-46c6-ba52-a870c76b9ee7"
}
```

| Claim                     | Description                                                                        |
| ------------------------- | ---------------------------------------------------------------------------------- |
| `aud`                     | Audience. Contains your app ID.                                                    |
| `client_id`               | The OIDC client the token was issued to                                            |
| `exp`                     | Expiry timestamp in Unix seconds. App tokens are valid for approximately one hour. |
| `iat`                     | Issue timestamp in Unix seconds                                                    |
| `iss`                     | The issuing identity provider                                                      |
| `jti`                     | Unique token identifier                                                            |
| `nbf`                     | Not-before timestamp in Unix seconds                                               |
| `sub`                     | The signed-in merchant's user ID                                                   |
| `urn:jtl:tenant_id`       | The tenant the token is scoped to. Use this in the `X-Tenant-ID` header.           |
| `urn:jtl:kundencenter_id` | The merchant's JTL customer account number                                         |

<Note>
  The app token carries no profile claims. To read a merchant's name or email, call the identity provider's userinfo endpoint.
</Note>

## What Verification Checks

Verification is three separate checks. A token is valid only when all three pass.

| Check                | Passes when                                                                                                                            |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Signature and issuer | The signature validates against the identity provider's public keys, `iss` matches your issuer, and `exp` is present and in the future |
| Audience             | `aud` contains your app ID                                                                                                             |
| App ID               | `urn:jtl:app_id` is absent, or equals your app ID                                                                                      |

The public keys are published at `${JTL_ISSUER}/oauth/v2/keys`. The token header carries a `kid`, and the key with the matching `kid` is the one that verifies it.

<Warning>
  Reject the request when any check fails. A token that carries a valid signature but a different audience was minted for another app, and treating it as valid would let that app act through yours.
</Warning>

## Verify the Token

Your frontend sends the token as `Authorization: Bearer <token>`. Your backend verifies it before acting on the request.

<CodeGroup>
  ```typescript TypeScript theme={null}
  // The TypeScript SDK provides `verifyAppToken` to perfom the checks:
  // lib/verify-app-token.ts
  import { verifyAppToken } from '@jtl-software/cloud-apps-auth/verify';

  const JTL_ISSUER = (process.env.JTL_ISSUER || 'https://id.jtl-cloud.com').replace(/\/$/, '');
  const APP_ID = (process.env.JTL_APP_ID || '').trim();

  export async function verify(authHeader: string) {
    const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';

    if (!token) {
      throw new Error('Missing Bearer app token');
    }

    const result = await verifyAppToken(token, { issuer: JTL_ISSUER, appId: APP_ID });

    if (!result.valid) {
      throw new Error(`App token verification failed: ${JSON.stringify(result.checks)}`);
    }

    return result.claims;
  }
  ```

  ```csharp C# theme={null}
  // VerifyAppToken.cs
    using Microsoft.Extensions.Configuration;
    using Microsoft.IdentityModel.JsonWebTokens;
    using Microsoft.IdentityModel.Protocols;
    using Microsoft.IdentityModel.Protocols.OpenIdConnect;
    using Microsoft.IdentityModel.Tokens;

    public static class VerifyAppToken
    {
        private static ConfigurationManager<OpenIdConnectConfiguration>? _configManager;

        // `configuration` is whatever you already have injected (FastEndpoints endpoints get it via
        // DI; from a minimal API handler, add `IConfiguration configuration` as a parameter).
        public static async Task<IDictionary<string, object>> VerifyAsync(
            string authHeader, IConfiguration configuration, CancellationToken ct = default)
        {
            var issuer = (configuration["JtlPlatform:Issuer"] ?? "https://id.jtl-cloud.com").TrimEnd('/');
            var appId = (configuration["JtlPlatform:AppId"] ?? "").Trim();

            var token = authHeader.StartsWith("Bearer ") ? authHeader[7..] : "";
            if (string.IsNullOrEmpty(token))
                throw new UnauthorizedAccessException("Missing Bearer app token");

            // Caches the IdP's published keys and refreshes them automatically (including retrying on
            // a signature failure), so a signing-key rotation on the IdP doesn't strand a long-running
            // process on a stale key set.
            _configManager ??= new ConfigurationManager<OpenIdConnectConfiguration>(
                $"{issuer}/.well-known/openid-configuration",
                new OpenIdConnectConfigurationRetriever(),
                new HttpDocumentRetriever());

            var config = await _configManager.GetConfigurationAsync(ct);
            var parameters = new TokenValidationParameters
            {
                ValidIssuer = issuer,
                IssuerSigningKeys = config.SigningKeys,
                ValidateIssuer = true,
                ValidateLifetime = true,
                RequireExpirationTime = true,
                // Audience is checked manually below, alongside urn:jtl:app_id, so a mismatch doesn't
                // get lost inside one opaque validation exception.
                ValidateAudience = false,
            };

            var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, parameters);
            if (!result.IsValid)
                throw new UnauthorizedAccessException(result.Exception?.Message ?? "Invalid app token");
          var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, parameters);
          if (!result.IsValid)
              throw new UnauthorizedAccessException(result.Exception?.Message ?? "Invalid app token");

          if (!ExtractAudience(result.Claims).Contains(appId))
              throw new UnauthorizedAccessException("Token audience does not contain this app");

          var hasAppIdClaim = result.Claims.TryGetValue("urn:jtl:app_id", out var appIdClaim);
          if (hasAppIdClaim && appIdClaim?.ToString() != appId)
              throw new UnauthorizedAccessException("Token was minted for a different app");

          return result.Claims;
      }

      private static IReadOnlyList<string> ExtractAudience(IDictionary<string, object> claims)
      {
          if (!claims.TryGetValue("aud", out var aud) || aud is null)
              return [];

          // `aud` can come back as a plain string or a list, depending on how many audiences the
          // token has.
          if (aud is IEnumerable<object> list)
              return list.Select(a => a.ToString() ?? "").ToList();

          return [aud.ToString() ?? ""];
      }
  }
  ```

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

  namespace App\Jtl;

  use Firebase\JWT\JWK;
  use Firebase\JWT\JWT;
  use GuzzleHttp\Client;
  use UnexpectedValueException;

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

      public static function verify(string $authHeader): array
      {
          $issuer = rtrim(getenv('JTL_ISSUER') ?: 'https://id.jtl-cloud.com', '/');
          $appId = trim(getenv('JTL_APP_ID') ?: '');

          $token = str_starts_with($authHeader, 'Bearer ') ? substr($authHeader, 7) : '';

          if ($token === '') {
              throw new UnexpectedValueException('Missing Bearer app token');
          }

          // Fetched fresh each call rather than cached: a stale cache would silently break
          // verification the moment the IdP rotates its signing keys.
          $keys = JWK::parseKeySet(json_decode(
              (string) self::httpClient()->get("{$issuer}/oauth/v2/keys")->getBody(),
              true,
              flags: JSON_THROW_ON_ERROR
          ));

          $decoded = JWT::decode($token, $keys);
          $claims = json_decode((string) json_encode($decoded), true);

          if (($claims['iss'] ?? null) !== $issuer) {
              throw new UnexpectedValueException('Token issuer does not match');
          }

          if (!isset($claims['exp'])) {
              throw new UnexpectedValueException('Token has no expiry');
          }

          $audience = (array) ($claims['aud'] ?? []);
          if (!in_array($appId, $audience, true)) {
              throw new UnexpectedValueException('Token audience does not contain this app');
          }

          $appIdClaim = $claims['urn:jtl:app_id'] ?? null;
          if ($appIdClaim !== null && $appIdClaim !== $appId) {
              throw new UnexpectedValueException('Token was minted for a different app');
          }

          return $claims;
      }

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

The TypeScript path uses `verifyAppToken`, which performs all checks and returns a per-check breakdown in `result.checks` rather than throwing, so you can report which check failed. The C# and PHP paths implement the same three checks directly.

<Tip>
  Cache the key set rather than fetching it on every request. Public keys change infrequently, and the samples above hold the set for the process lifetime.
</Tip>

## Use the Token to Identify the Tenant

After verifying the token, use the `urn:jtl:tenant_id` claim to identify the tenant associated with the request.

### Fullstack Apps (Embedded Views)

If your app has a backend, the flow is:

1. Receive the app token from the frontend using the AppBridge.
2. Verify the token.
3. Read `urn:jtl:tenant_id` from the verified claims.
4. Obtain a service account token.
5. Call the JTL-Wawi via GraphQL using the service account token and the tenant ID.

<CodeGroup>
  ```typescript TypeScript theme={null}
  // routes/graphql.ts
  import express, { Request, Response } from 'express';
  import { verify } from '../lib/verify-app-token';
  import { getCachedAccessToken } from '../lib/token-cache';
   
  const app = express();
   
  app.post('/graphql', async (req: Request, res: Response) => {
      let tenantId: string;
   
      try {
          const claims = await verify(req.headers.authorization ?? '');
          const claim = claims?.['urn:jtl:tenant_id'];
   
          if (typeof claim !== 'string') {
              return res.status(400).json({ error: 'App token has no tenant claim' });
          }
   
          tenantId = claim;
      } catch (error) {
          return res.status(401).json({ error: 'Failed to verify app token' });
      }
   
      const accessToken = await getCachedAccessToken();
   
      const response = await fetch('https://api.jtl-cloud.com/erp/v2/graphql', {
          method: 'POST',
          headers: {
              Authorization: `Bearer ${accessToken}`,
              'X-Tenant-ID': tenantId,
              'Content-Type': 'application/json',
          },
          body: JSON.stringify(req.body),
      });
   
      return res
          .status(response.status)
          .type('application/json')
          .send(await response.text());
  });
  ```

  ```csharp C# theme={null}
  // GraphqlEndpoint.cs
  using System.IdentityModel.Tokens.Jwt;
  using System.Net.Http.Headers;
   
  public static class GraphqlEndpoint
  {
      private static readonly HttpClient HttpClient = new();
   
      public static async Task<IResult> HandleAsync(HttpRequest request)
      {
          JwtSecurityToken jwt;
   
          try
          {
              jwt = await VerifyAppToken.VerifyAsync(request.Headers.Authorization.ToString());
          }
          catch (Exception)
          {
              return Results.Json(new { error = "Failed to verify app token" }, statusCode: 401);
          }
   
          var tenantId = jwt.Claims.FirstOrDefault(c => c.Type == "urn:jtl:tenant_id")?.Value;
   
          if (string.IsNullOrEmpty(tenantId))
              return Results.Json(new { error = "App token has no tenant claim" }, statusCode: 400);
   
          var accessToken = await TokenCache.GetCachedAccessTokenAsync();
   
          using var reader = new StreamReader(request.Body);
          var body = await reader.ReadToEndAsync();
   
          var upstream = new HttpRequestMessage(
              HttpMethod.Post,
              "https://api.jtl-cloud.com/erp/v2/graphql"
          );
          upstream.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
          upstream.Headers.Add("X-Tenant-ID", tenantId);
          upstream.Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json");
   
          var response = await HttpClient.SendAsync(upstream);
   
          return Results.Content(
              await response.Content.ReadAsStringAsync(),
              "application/json",
              statusCode: (int)response.StatusCode
          );
      }
  }
  ```

  ```php PHP theme={null}
  <?php
  // src/Jtl/GraphqlController.php
  declare(strict_types=1);
   
  namespace App\Jtl;
   
  use GuzzleHttp\Client;
  use Psr\Http\Message\ResponseInterface;
  use Psr\Http\Message\ServerRequestInterface;
  use Throwable;
   
  final class GraphqlController
  {
      private const GRAPHQL_ENDPOINT = 'https://api.jtl-cloud.com/erp/v2/graphql';
   
      public function __construct(private readonly Client $httpClient) {}
   
      public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
      {
          try {
              $claims = VerifyAppToken::verify($request->getHeaderLine('Authorization'));
          } catch (Throwable) {
              return self::json($response, ['error' => 'Failed to verify app token'], 401);
          }
   
          $tenantId = $claims['urn:jtl:tenant_id'] ?? null;
   
          if (!is_string($tenantId)) {
              return self::json($response, ['error' => 'App token has no tenant claim'], 400);
          }
   
          $accessToken = TokenCache::getCachedAccessToken();
   
          $upstream = $this->httpClient->post(self::GRAPHQL_ENDPOINT, [
              'headers' => [
                  'Authorization' => "Bearer {$accessToken}",
                  'X-Tenant-ID' => $tenantId,
                  'Content-Type' => 'application/json',
              ],
              'body' => (string) $request->getBody(),
              'http_errors' => false,
          ]);
   
          $response->getBody()->write((string) $upstream->getBody());
   
          return $response
              ->withHeader('Content-Type', 'application/json')
              ->withStatus($upstream->getStatusCode());
      }
   
      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>

The app token identifies the tenant. The service account token determines what the backend can do. The scopes declared in your manifest control the permissions available to the service account.

See [Service Account Authentication](/cloud/guides/cloud-apps/service-account-authentication) for obtaining and caching that token, and [Scopes & Permissions](/cloud/guides/essentials/authentication/scopes-permissions#how-scopes-are-enforced) for how the two tokens differ.

### Standalone Frontend Apps: Browser Sign-in

If your app has no backend, it can call the API directly from the browser using the app token. In this case, the signed-in merchant's own permissions apply. See [Standalone Authentication](/cloud/guides/cloud-apps/standalone-authentication) for the implementation.

## Tenant Mapping

Your backend should store a record that links the JTL tenant to your app's internal state.

Without this mapping, your backend cannot associate an incoming request with the merchant's existing account or data.

Use persistent storage rather than in-memory storage. In-memory data is lost when the server restarts and cannot be shared between multiple server instances.

### What to Store

At minimum, persist the following the first time a merchant reaches your backend.

| Field               | Where it comes from           | Why you need it                                   |
| ------------------- | ----------------------------- | ------------------------------------------------- |
| `tenantId`          | The `urn:jtl:tenant_id` claim | Primary key, identifies the merchant              |
| `installedAt`       | Your server timestamp         | Provides an audit trail and helps with debugging. |
| `installedByUserId` | The `sub` claim               | Identifies the user who first connected the app.  |

If your app has its own user or account model, link the tenant ID to your internal record.

A minimal PostgreSQL schema:

```sql theme={null}
CREATE TABLE jtl_tenants (
  tenant_id      UUID PRIMARY KEY,
  installed_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  installed_by   UUID NOT NULL,
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

### Writing the Record

Use an upsert rather than an insert. A merchant may connect your app more than once, and an existing tenant should update its record instead of causing a duplicate-key error.

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { Pool } from 'pg';
  import { verify } from '../lib/verify-app-token';

  const pool = new Pool();

  app.post('/api/connect', async (req, res) => {
    try {
      const claims = await verify(req.headers.authorization ?? '');

      await pool.query(
        `INSERT INTO jtl_tenants (tenant_id, installed_by)
         VALUES ($1, $2)
         ON CONFLICT (tenant_id) DO UPDATE
             SET installed_by = EXCLUDED.installed_by,
                 updated_at   = NOW()`,
        [claims?.['urn:jtl:tenant_id'], claims?.sub]
      );

      return res.json({ tenantId: claims?.['urn:jtl:tenant_id'] });
    } catch {
      return res.status(401).json({ error: 'Failed to verify app token' });
    }
  });
  ```

  ```csharp C# theme={null}
  using Npgsql;

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

      public static async Task<IResult> HandleAsync(HttpRequest request)
      {
          try
          {
              var jwt = await VerifyAppToken.VerifyAsync(request.Headers.Authorization.ToString());

              var tenantId = jwt.Claims.First(c => c.Type == "urn:jtl:tenant_id").Value;
              var userId = jwt.Claims.First(c => c.Type == "sub").Value;

              await using var cmd = DataSource.CreateCommand(
                  """
                  INSERT INTO jtl_tenants (tenant_id, installed_by)
                  VALUES ($1, $2)
                  ON CONFLICT (tenant_id) DO UPDATE
                      SET installed_by = EXCLUDED.installed_by,
                          updated_at   = NOW()
                  """
              );
              cmd.Parameters.AddWithValue(Guid.Parse(tenantId));
              cmd.Parameters.AddWithValue(Guid.Parse(userId));
              await cmd.ExecuteNonQueryAsync();

              return Results.Json(new { tenantId });
          }
          catch (UnauthorizedAccessException)
          {
              return Results.Json(new { error = "Failed to verify app token" }, statusCode: 401);
          }
      }
  }
  ```

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

  namespace App\Jtl;

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

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

      public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
      {
          try {
              $claims = VerifyAppToken::verify($request->getHeaderLine('Authorization'));
          } catch (Throwable) {
              return self::json($response, ['error' => 'Failed to verify app token'], 401);
          }

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

          $stmt->execute([
              ':tenant_id' => $claims['urn:jtl:tenant_id'],
              ':installed_by' => $claims['sub'],
          ]);

          return self::json($response, ['tenantId' => $claims['urn:jtl:tenant_id']]);
      }

      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>

### Reading the Record

On each incoming request, verify the token, take the tenant ID from its claims, and look up your record:

```typescript theme={null}
const claims = await verify(req.headers.authorization ?? '');

const result = await pool.query(
  'SELECT * FROM jtl_tenants WHERE tenant_id = $1',
  [claims?.['urn:jtl:tenant_id']]
);

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

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

### What Not to Do

A few patterns cause most tenant-mapping bugs in production.

| Don't                                                    | Why                                                                               |
| -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Store tenant mappings only in memory                     | Wiped on every restart, does not survive multiple instances                       |
| Trust a tenant ID sent in a request body, URL, or header | A client can send any value. Take it from the verified token's claims.            |
| Store the app token itself                               | App tokens expire within the hour. Store the tenant ID they prove, not the token. |
| Assume tenant IDs are sequential or predictable          | They are UUIDs. Treat them as opaque identifiers.                                 |

## Token Lifetime

App tokens are valid for approximately one hour. Request a fresh token rather than holding one across a long-running session.

```typescript theme={null}
const { accessToken } = await appBridge.method.call<{ accessToken: string }>(
  'getAppToken'
);
```

Calling `getAppToken` before each backend request keeps the token current without requiring your app to handle token expiry itself.

## Common Errors

The failures you are most likely to hit, and what causes them.

<AccordionGroup>
  <Accordion title="Verification fails on the audience check">
    The `aud` claim does not contain your app ID. `JTL_APP_ID` is the GUID shown in the Partner Portal under your app. Compare the value in your `.env` against the one in the portal.
  </Accordion>

  <Accordion title="Verification fails on signature or issuer">
    The token was issued by a different environment than the one your backend is verifying against. Confirm `JTL_ISSUER` matches the environment your app is registered in.
  </Accordion>

  <Accordion title="Verification fails on a token that worked earlier">
    The token has expired. App tokens are valid for approximately one hour, so a token captured during debugging stops verifying. Request a fresh one with `getAppToken`.
  </Accordion>

  <Accordion title="API calls return 403 after successful verification">
    The token verified, so the merchant and tenant are known, but the call exceeded what the calling credential is permitted to do. A backend calling with a service account is bound by the scopes declared in your manifest. See [Scopes & Permissions](/cloud/guides/essentials/authentication/scopes-permissions).
  </Accordion>
</AccordionGroup>

## What's Next?

<CardGroup cols={2}>
  <Card title="Service Account Authentication" icon="server" href="/cloud/guides/cloud-apps/service-account-authentication">
    Obtain and cache the access token your backend uses to call the JTL Cloud API.
  </Card>

  <Card title="Using Platform APIs" icon="database" href="/cloud/guides/cloud-apps/using-platform-apis">
    Call the JTL Cloud and JTL-Wawi APIs with the right headers and scoping.
  </Card>

  <Card title="Standalone Authentication" icon="user-check" href="/cloud/guides/cloud-apps/standalone-authentication">
    Sign merchants in from an app running on your own domain.
  </Card>

  <Card title="Scopes & Permissions" icon="shield" href="/cloud/guides/essentials/authentication/scopes-permissions">
    Control what your app can access, and how enforcement differs by token.
  </Card>
</CardGroup>
