Skip to main content

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.

Build the ASP.NET Core backend for a JTL Cloud App using .NET 8 and NSec.Cryptography for Ed25519 signature verification. By the end, the backend runs locally and the frontend’s “Waiting for backend” placeholder turns into a real connection. Stack: .NET 8, ASP.NET Core Web API, NSec.Cryptography for JWT verification.

Prerequisites

You need:
  • A finished frontend from the Build the Frontend page, running locally on http://localhost:5173
  • .NET SDK 8.0 or higher. Run dotnet --version to check.

What you’re Building

During setup, your backend verifies that the session token from your frontend is valid and was issued by JTL. To do this:
  • Your backend authenticates with JTL using its client credentials
  • Fetches JTL’s public keys (JWKS)
  • And uses them to verify the session token’s signature
Once verified, the token tells your backend which tenant (merchant) and user is using your app. Your backend can then make tenant-scoped requests to the JTL Cloud API on their behalf.

1. Set up the Project

Create a Backend folder alongside the existing frontend folder, then scaffold a Web API project inside it.
cd my-jtl-app
dotnet new webapi -n Backend
Your project structure now looks like this:
my-jtl-app/
├── frontend/      # From the previous page
└── Backend/       # New
The dotnet new webapi template includes a sample WeatherForecast controller and model. You can leave these in place or delete them. They won’t affect anything you build in this guide.

2. Install Packages

cd Backend
dotnet add package NSec.Cryptography
PackagePurpose
NSec.CryptographyCryptographic primitives for Ed25519 signature verification, used to validate JTL session tokens

3. Set up Environment Variables

ASP.NET Core uses appsettings.json for configuration. For local secrets, use the .NET user-secrets tool so credentials never end up in source control. From the Backend/ directory:
dotnet user-secrets init
dotnet user-secrets set "Jtl:ClientId" "your-client-id-here"
dotnet user-secrets set "Jtl:ClientSecret" "your-client-secret-here"
The values are placeholders for now. You’ll get real values from the Partner Portal in the next page and update them with the same dotnet user-secrets set commands. For production, swap user-secrets for environment variables, Azure Key Vault, or whichever secrets manager fits your hosting platform.

4. Build the Auth Service

The first piece of the backend is a service that authenticates with JTL using your client credentials and returns an access token. This token has two uses: fetching the public keys for session token verification, and making tenant-scoped calls to the JTL Cloud API. Create Backend/Services/JtlAuthService.cs:
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

namespace Backend.Services;

public class JtlAuthService
{
    private readonly HttpClient _http;
    private readonly IConfiguration _config;

    public JtlAuthService(HttpClient http, IConfiguration config)
    {
        _http = http;
        _config = config;
    }

    public string ApiBaseUrl => "https://api.jtl-cloud.com";
    public string AuthEndpoint => "https://auth.jtl-cloud.com/oauth2/token";

    public async Task<string> GetJwtAsync()
    {
        var clientId = _config["Jtl:ClientId"];
        var clientSecret = _config["Jtl:ClientSecret"];

        if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
        {
            throw new InvalidOperationException(
                "Jtl:ClientId and Jtl:ClientSecret must be configured.");
        }

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

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

        var response = await _http.SendAsync(request);
        var body = await response.Content.ReadAsStringAsync();

        if (!response.IsSuccessStatusCode)
        {
            throw new HttpRequestException(
                $"Failed to fetch JWT ({(int)response.StatusCode}): {body}");
        }

        using var doc = JsonDocument.Parse(body);
        return doc.RootElement.GetProperty("access_token").GetString()!;
    }
}
The service reads its credentials from IConfiguration (which user-secrets feeds into automatically), encodes them as Basic auth, and sends a client_credentials grant request to JTL’s auth endpoint. The token is short-lived, so the method fetches a fresh one on each call. For higher-traffic backends you’d add caching.

5. Build the Session Verifier

With an access token in hand, the backend can fetch the public keys it needs to verify session tokens. JTL signs session tokens with Ed25519. Create Backend/Services/SessionVerifier.cs:
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using NSec.Cryptography;

namespace Backend.Services;

public record SessionTokenPayload(
    long Exp,
    string UserId,
    string TenantId,
    string? TenantSlug,
    string Kid);

public class SessionVerifier
{
    private readonly HttpClient _http;
    private readonly JtlAuthService _auth;
    private readonly ILogger<SessionVerifier> _logger;

    public SessionVerifier(
        HttpClient http,
        JtlAuthService auth,
        ILogger<SessionVerifier> logger)
    {
        _http = http;
        _auth = auth;
        _logger = logger;
    }

    public async Task<SessionTokenPayload> VerifyAsync(string sessionToken)
    {
        var jwt = await _auth.GetJwtAsync();

        var request = new HttpRequestMessage(
            HttpMethod.Get,
            $"{_auth.ApiBaseUrl}/account/.well-known/jwks.json");
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);

        var response = await _http.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);

        // JTL session tokens are signed with Ed25519 (OKP)
        var key = jwks.GetProperty("keys")[0];
        var publicKeyBytes = Base64UrlDecode(key.GetProperty("x").GetString()!);

        return VerifyAndDecode(sessionToken, publicKeyBytes);
    }

    private 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))
        {
            _logger.LogError("JWT signature verification failed");
            throw new UnauthorizedAccessException("Invalid token signature.");
        }

        var headerJson = Encoding.UTF8.GetString(Base64UrlDecode(parts[0]));
        var header = JsonSerializer.Deserialize<JsonElement>(headerJson);
        var kid = header.TryGetProperty("kid", out var k)
            ? k.GetString() ?? string.Empty
            : string.Empty;

        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,
            Kid: kid);
    }

    private static byte[] Base64UrlDecode(string input)
    {
        var padded = input.Replace('-', '+').Replace('_', '/');
        padded += (padded.Length % 4) switch
        {
            2 => "==",
            3 => "=",
            _ => ""
        };
        return Convert.FromBase64String(padded);
    }
}
There are two things going on inside this service. The public VerifyAsync method fetches the JWKS document and pulls out the first public key. The private VerifyAndDecode method splits the JWT into its three parts (header, payload, signature), reconstructs the signed data, and asks NSec to verify the signature against the public key. If the signature is valid and the token isn’t expired, it returns the decoded payload as a strongly-typed record.
In production, select the correct key by matching the kid in the session token’s header against the keys in the JWKS response. This example uses the first key for simplicity, which works as long as the JWKS only contains one key.

6. Build the Connect Tenant Controller

Now wire the verifier into a controller. The frontend’s shell layout sends the session token to /api/connect-tenant and expects a tenant ID back. Create Backend/Controllers/ConnectTenantController.cs:
using Backend.Services;
using Microsoft.AspNetCore.Mvc;

namespace Backend.Controllers;

[ApiController]
[Route("api/connect-tenant")]
public class ConnectTenantController : ControllerBase
{
    private readonly SessionVerifier _verifier;
    private readonly ILogger<ConnectTenantController> _logger;

    public ConnectTenantController(
        SessionVerifier verifier,
        ILogger<ConnectTenantController> logger)
    {
        _verifier = verifier;
        _logger = logger;
    }

    [HttpGet]
    public async Task<IActionResult> Get()
    {
        var sessionToken = Request.Headers["X-Session-ID"].FirstOrDefault();

        if (string.IsNullOrWhiteSpace(sessionToken))
        {
            return BadRequest(new { error = "Session token is required" });
        }

        try
        {
            var payload = await _verifier.VerifyAsync(sessionToken);

            // In a production app, you'd store the tenant connection in your database here
            _logger.LogInformation("Tenant connected: {TenantId}", payload.TenantId);

            return Ok(new
            {
                success = true,
                tenantId = payload.TenantId,
                userId = payload.UserId,
                tenantSlug = payload.TenantSlug,
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Connection failed");
            return Unauthorized(new { error = "Failed to verify session token" });
        }
    }
}
The controller reads the session token from the X-Session-ID request header, hands it to the verifier, and returns the tenant details on success or a 401 on failure. The [ApiController] attribute handles model binding and validation automatically. See the Tenant Mapping section for more on managing tenants in production.

7. Wire up Services and CORS

ASP.NET Core needs to know about the services it should inject and the controllers it should expose. Replace the contents of Backend/Program.cs:
using Backend.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddHttpClient<JtlAuthService>();
builder.Services.AddHttpClient<SessionVerifier>();

// Allow the Vite dev server to call the API during development
builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.WithOrigins("http://localhost:5173")
              .AllowAnyHeader()
              .AllowAnyMethod();
    });
});

var app = builder.Build();

app.UseCors();
app.MapControllers();

app.Run();
AddHttpClient<TService> does two things at once: it registers the service in the DI container and gives each instance its own HttpClient from the built-in factory. The CORS policy allows requests from the Vite dev server on port 5173. The dev proxy in vite.config.ts already routes frontend fetch('/api/...') calls to this backend, but the CORS middleware is a useful safety net during development.

8. Configure the Backend Port

By default, dotnet new webapi listens on a random port chosen at startup, but the frontend’s Vite dev proxy expects the backend on http://localhost:5273. Pin the port in Backend/Properties/launchSettings.json by replacing the applicationUrl value in the http profile:
{
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": false,
      "applicationUrl": "http://localhost:5273",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}
You can leave the other profiles (https, IIS Express) in place. The dotnet run command picks the http profile by default during development.

9. Run the Backend

Start the backend:
dotnet run
You should see output like:
Now listening on: http://localhost:5273
Application started. Press Ctrl+C to shut down.
In a second terminal, send a request with a fake session token to confirm the route is reachable:
curl http://localhost:5273/api/connect-tenant \
  -H "X-Session-ID: fake-token"
You should get back a 401 response with {"error":"Failed to verify session token"}. That’s the expected outcome for an invalid token. The route is alive, the request was parsed, and the verifier ran and rejected the token. A real session token from the App Shell will follow the same path and succeed.

Common Issues

This error means the backend started but couldn’t find your credentials in configuration. The most common cause is that user-secrets weren’t initialised, or were set in a different directory than the project expects.User-secrets are scoped to a specific project, identified by the UserSecretsId GUID in the .csproj file. Running dotnet user-secrets list from inside the Backend/ directory will show whether the secrets are present for this project. If the list comes back empty, running dotnet user-secrets init followed by the two dotnet user-secrets set commands from earlier in the guide will populate them.It’s also worth confirming that you ran the user-secrets commands from the Backend/ directory and not the project root. The tool resolves the target project based on the current working directory.
A 401 from JTL’s auth endpoint means the credentials being sent aren’t recognised. Until you’ve registered your app in the Partner Portal, this is the expected response, since the placeholder values from earlier aren’t real credentials. The next page in this guide walks through registration and provides the real values.If you’ve already registered the app and are still seeing this error, the most likely causes are typos in the user-secrets values and forgetting to restart the backend after updating them. ASP.NET Core reads configuration once at startup, so changes to user-secrets take effect only on the next dotnet run. The dotnet user-secrets list command is the fastest way to confirm the stored values match what the Partner Portal showed you.
A CORS error usually means the request reached the backend but the browser blocked the response because the origin didn’t match what the backend allows. The CORS policy in Program.cs is configured to allow http://localhost:5173, which matches the default Vite dev server port.If the Vite dev server is running on a different port (for example, because port 5173 was already in use and Vite picked 5174 instead), the browser will see a mismatch. Updating the WithOrigins value in Program.cs to match the actual Vite port, or freeing up port 5173, should resolve it.Worth noting: when the Vite dev proxy is forwarding /api/* requests, the browser shouldn’t actually see CORS at all, since the requests look same-origin from the browser’s perspective. CORS errors usually appear when something is calling the backend directly on localhost:5273 instead of going through the proxy.
If dotnet run reports a different port (for example, 5000 or a random high number), the launchSettings.json change from earlier in the guide either hasn’t been saved or isn’t being picked up.Confirming that Backend/Properties/launchSettings.json contains "applicationUrl": "http://localhost:5273" in the http profile, then stopping and restarting dotnet run, should pin the port. If dotnet run is using a different profile than expected, passing --launch-profile http explicitly will force it to use the right one.An alternative if you’d rather not edit launchSettings.json is to set the ASPNETCORE_URLS environment variable: ASPNETCORE_URLS=http://localhost:5273 dotnet run. This works without any config file changes.

Next: Connect and Fetch Data

The backend verifies session tokens and is ready to call the JTL Cloud API. The remaining work is registering your app with JTL to get real credentials, installing the app in the JTL Hub, and pulling product data from JTL-Wawi:

Connect and Fetch Data

Register your app, install it in the JTL Hub, and fetch products from JTL-Wawi.