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:
user_guid— unique user identifiertenant_id— internal tenant IDtenant_guid— used to resolve the correct signing keyemailpermissions— list of permission strings assigned to the user's roleentity_type— either"supplier"or"customer", derived from the user's entity at login time
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:
tenant_guidclaim inside the JWT — primary method for all authenticated requestsX-Tenantheader — 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:
POST /api/v1/auth/loginPOST /api/v1/auth/refresh
Paths that bypass middleware entirely (no tenant resolution):
/swagger,/docs,/css,/api/Data/GenerateToken
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:
- Returns
nullforAllscope — no filter applied - Returns the user's
UserEntityIdforSelfscope
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:
- After model binding, the filter inspects the request body for any property named
EntityIdorEntityID - If the user's scope is
Self, that property is overwritten with the user's ownUserEntityIdregardless of what was submitted All-scoped users are unaffected — they can POST against any entity
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:
accessToken— short-lived JWTrefreshToken— long-lived token used with/auth/refreshentityType—"supplier"or"customer", derived from the user's entity
The GET /api/v1/auth/me endpoint returns the current user context from the live JWT:
tokenId— current session token identifieruserGuid— unique user identifierfirstName,lastNametenantId,tenantGuid,tenantNameappIduserEntityIdroleentityType—"supplier"or"customer"scope—"Self"or"All"permissions— list of permission stringsfeatures— tenant feature flags
Known Limitations / Pending Review
- Endpoints that require more than one permission (e.g. either
orders.readORreports.read) will need additional logic —[RequirePermission]currently checks a single permission. - Some endpoints may need different scope rules depending on the operation (e.g. a
Self-scoped user reading a shared resource). These should be reviewed as they arise. - Legacy API token fallback is still in place for backward compatibility and should be phased out once all clients are migrated to JWT.
- The auth layer is intentionally kept simple at this stage — complexity should be added in response to real requirements, not pre-emptively.