API Authentication & Authorization — JWT Overview

Status: Core logic implemented. Auth layer is functional but will require ongoing review as new endpoints are developed and edge cases (e.g. multi-permission requirements, mixed scope scenarios) are identified.


How JWT Authentication Works

Each request must include a valid JWT in the Authorization header:

Authorization: Bearer <token>

Tokens are issued on login and contain the following claims:

Tokens are signed with HS512 using a per-tenant IssuerSigningKey stored against the tenant record. This means token validation is tenant-scoped — a token signed for Tenant A cannot be validated against Tenant B's key.

Sessions are tracked server-side. On each request the middleware checks the session has not been revoked (cached for 5 minutes for performance).


Identifying the Tenant

The middleware resolves the tenant through the following priority order:

  1. tenant_guid claim inside the JWT — primary method for all authenticated requests
  2. X-Tenant header — UUID of the tenant, used when no JWT is present (e.g. anonymous endpoints like login/refresh)

For authenticated requests the tenant is resolved from the JWT itself — no extra headers are needed.

Example header for anonymous / pre-auth requests:

X-Tenant: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

If X-Tenant is missing or the GUID does not match an active tenant, the request is rejected with 403.


Anonymous Access ([AllowAnonymous])

Endpoints decorated with [AllowAnonymous] do not require a Bearer token. However, the middleware still runs and requires X-Tenant to be present so the correct tenant context (database connection, app ID, etc.) is available to the endpoint.

Current anonymous endpoints:

Paths that bypass middleware entirely (no tenant resolution):


Permission System

Permissions are assigned to roles and stored as strings in the JWT. Endpoints declare required permissions using the [RequirePermission] attribute:

[RequirePermission(Permissions.Orders.Write)]

If the user's token does not contain the required permission the request returns 403 Forbidden.

Defined permissions:

Area Permissions
Orders orders.read, orders.write, orders.approve
Products products.read, products.write
Pricing pricing.read, pricing.write, pricing.override
Users users.read, users.write, users.manage
Inventory inventory.read, inventory.write
Documents documents.read, documents.download
Reports reports.read, reports.export
Settings settings.read, settings.write
Full full.read, full.manage

Permissions are role-based — loaded from the user's role at login and embedded in the JWT. Role permissions are cached for 10 minutes.


Access Scope

Every user has an AccessScope derived from their role:

Scope Behaviour
AccessScope.Self User can only read/write data belonging to their own entity
AccessScope.All User can access all entity data within the tenant (admin-level)

Queries are filtered automatically via the EntityFilter helper in BaseController:


Entity Scope Enforcement on POST (EnforceEntityScopeFilter)

On POST requests, the EnforceEntityScopeFilter global filter automatically enforces scope to prevent a Self-scoped user from inserting records against another entity.

How it works:

This means Self-scoped users cannot manipulate which entity a new record is associated with, even if they craft the request manually.


Auth Flow Summary

Request
  │
  ├─ Public path? ──► Skip middleware entirely
  │
  ├─ [AllowAnonymous]? ──► Require X-Tenant header, resolve tenant only (no user auth)
  │
  └─ Authenticated path
       │
       ├─ Extract tenant_guid from JWT
       ├─ Lookup IssuerSigningKey from TenantDomain
       ├─ Validate JWT signature + claims
       ├─ Check session not revoked
       ├─ Load UserEntityId + AccessScope from DB
       ├─ Extract entity_type claim → set context.Items["UserEntityType"]
       └─ Set context → proceed to controller
                            │
                            ├─ [RequirePermission] → 403 if missing
                            └─ EnforceEntityScopeFilter (POST only)

Rate Limiting

The API applies rate limiting per client IP address to protect against abuse and brute-force attacks.

Policy Applies to Limit
Global All endpoints 500 requests / minute (sliding window)
Login POST /api/v1/auth/login 5 attempts / 15 minutes
AccountWrite register, reset-password, confirm 10 attempts / 15 minutes
GenerateToken GET /api/Data/GenerateToken 5 requests / minute

When a rate limit is exceeded the API returns 429 Too Many Requests.

For authentication endpoints (/auth/login, /auth/register, /auth/reset-password, /auth/confirm) an additional 2-second delay is applied on rejection to further deter brute-force attempts.

The Login policy is partitioned per IP and email address — a single attacker cannot lock out multiple accounts simultaneously at scale.


Login Response Fields

On successful login (POST /api/v1/auth/login and POST /api/v1/auth/refresh) the server returns a LoginResponse:

The GET /api/v1/auth/me endpoint returns the current user context from the live JWT:


Known Limitations / Pending Review