Skip to main content

Authentication & Authorization

Volume 3 — Modern Development

These two words get used almost interchangeably in casual conversation, and confusing them is exactly why 401 and 403 get confused too. This chapter draws the line clearly, then covers the mechanics — JWTs, OAuth2/OIDC, and specifically Azure Entra External ID (CIAM), the identity provider behind Traxs authentication.

1. Authentication vs. Authorization

Authentication (AuthN)Authorization (AuthZ)
Question answeredWho are you?What are you allowed to do?
HappensOnce, at login (or per-request via a token proving a prior login)On every action, potentially differently per action
Failure status401 Unauthorized403 Forbidden
Traxs exampleLogging in via Azure Entra External IDWhether a logged-in user with the TenantAdmin role can approve leave for someone else

A user can be fully authenticated and still be authorized for almost nothing — a valid login proves identity, not permission.

2. JWT Structure

A JWT (JSON Web Token) is the most common way authentication state is carried across stateless HTTP requests — the token itself proves who the caller is, without the server needing to keep any session data.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsInJvbGUiOiJUZW5hbnRBZG1pbiJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└─────────── header ───────────┘└────────────── payload ──────────────┘└──────────── signature ───────────┘

Three parts, separated by dots, each Base64Url-encoded:

PartContains
HeaderWhich algorithm was used to sign it (e.g. RS256)
PayloadThe claims — statements about the user: sub (subject/user ID), roles, tenant, expiration (exp), issuer (iss), audience (aud), and any custom claims the identity provider adds
SignatureCryptographic proof the token wasn't tampered with, verified against the issuer's public key

Anyone can decode a JWT's header and payload — they're just Base64, not encrypted — so a JWT should never carry secrets in its claims. What makes it trustworthy isn't secrecy, it's the signature: the API verifies the signature against the identity provider's public key before trusting anything in the payload.

3. OAuth2 and OIDC, Briefly

OAuth2 is an authorization framework — originally designed for letting one application access resources on another's behalf without sharing a password. OIDC (OpenID Connect) is built on top of OAuth2 specifically to add authentication (proving who the user is, via an ID token), which plain OAuth2 alone doesn't define.

Two flows worth knowing by name:

FlowUsed for
Authorization Code flowA human logging in through a browser — the standard flow for web/mobile apps, and what Azure Entra External ID uses for user sign-in
Client Credentials flowMachine-to-machine authentication with no human involved — a service authenticating as itself, using a client ID and secret, not a user's credentials

4. Azure Entra External ID (CIAM) — Claim Mapping

Azure Entra External ID (Customer Identity and Access Management) is the identity provider behind Traxs authentication. One detail that consistently trips people up:

Tokens from Entra External ID use full schema URL claim names, not short names. Where you might expect a claim simply named role, the actual claim key is a full URL:

{
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "TenantAdmin",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "pete@traxsgroup.com"
}

Code that reads User.FindFirst("role") expecting a short claim name will silently find nothing — not throw an error, just return null — because the actual key in the token is the full schema URL above. The fix is mapping explicitly to the correct schema URL constant (or using the framework's typed claim helpers, which already know the mapping) rather than assuming short names.

var role = User.FindFirst(ClaimTypes.Role)?.Value;
// ClaimTypes.Role resolves to the full schema URL under the hood — this is the safe way to read it

5. Role-Based vs. Claims-Based Authorization

ApproachHow it decides
Role-based (RBAC)"Is this user in the TenantAdmin role?" — simple, coarse-grained
Claims-based"Does this user have a claim proving they belong to Tenant X and have the Dispatcher role for that tenant?" — finer-grained, can combine multiple facts

Role-based checks are usually sufficient for simple "can this user reach this endpoint at all" decisions. Claims-based checks matter once authorization depends on more than a single flag — like confirming a TenantAdmin claim is scoped to the same tenant as the resource they're trying to act on, not just that they hold the role somewhere.

[Authorize(Roles = "TenantAdmin,Dispatcher")] // role-based — either role is sufficient
public class ApproveLeaveRequestEndpoint : Endpoint<ApproveLeaveRequestCommand>
{
public override async Task HandleAsync(ApproveLeaveRequestCommand req, CancellationToken ct)
{
var tenantId = User.FindFirst("tenantId")?.Value;
if (tenantId != req.TenantId.ToString())
{
await SendForbiddenAsync(ct); // claims-based check beyond the role alone
return;
}
// ...
}
}

This is directly the shape of the work tracked as endpoint role authorization (WAY-36) — applying exactly this kind of role- and claims-based check consistently across every endpoint, rather than leaving authorization implicit or endpoint-by-endpoint inconsistent.

6. Token Storage on the Client

StorageRisk
localStorageAccessible to any JavaScript running on the page — vulnerable to XSS: a single injected script can read and exfiltrate the token
httpOnly cookieNot accessible to JavaScript at all, meaningfully reducing XSS exposure — but requires CSRF protection instead, since the browser now attaches it automatically to every request
In-memory (JS variable, not persisted)Safest against XSS reading it at rest, but lost on page refresh — usually paired with a refresh-token flow to silently re-establish it

There's no storage option that's simply "safe" — each trades one attack surface for another. The choice should be a deliberate one made with the frontend framework and threat model in mind, not a default nobody decided on.

7. Token Expiration and Refresh

Access tokens are deliberately short-lived (often 15–60 minutes) to limit how long a leaked token remains useful. A refresh token (longer-lived, used only to obtain a new access token, never sent directly to application endpoints) lets a client stay logged in without re-prompting for credentials every time the access token expires.

Access token expires → client silently uses refresh token → identity provider issues a new access token → request retried

If this silent refresh isn't implemented, the visible symptom is a user getting logged out abruptly mid-session with no warning, exactly at the access token's expiration boundary — a bug that's easy to miss in short manual testing sessions and only shows up once someone's been actively using the app for the token's full lifetime.

8. Troubleshooting Playbook

SymptomLikely causeWhat to check
401 on every request despite a seemingly valid loginToken not actually attached, or Authorization: Bearer <token> header malformedConfirm the header is present and correctly formatted in the actual outgoing request, e.g. via curl -v or browser dev tools
Role check always fails even though the user has the roleReading a short claim name against a token using full schema URL claim namesUse ClaimTypes.Role (or the equivalent typed helper) instead of a raw string literal
Token valid one moment, 401 moments laterAccess token expired, refresh not implemented or failing silentlyConfirm token exp claim and actual elapsed session time; check refresh token flow logs
403 when the user insists they should have accessRole present, but a claims-based scoping check (like tenant match) is failingLog the specific claim values being compared, not just whether the role exists
"Invalid token signature"Token was issued by a different environment/tenant than the API is configured to trust, or AzureAd:Instance/issuer config is wrongConfirm the API's configured issuer/authority matches exactly what issued the token
Intermittent 401s under load, users complain of random logoutsClock skew between servers affecting exp/nbf validation, or a load balancer routing to instances with inconsistent time syncCheck server clock sync (NTP); most JWT validation libraries allow a small clock-skew tolerance — confirm it's configured, not zero
CORS error masking what's actually a 401/403Browser reports a generic CORS failure when the real response was blocked before CORS headers were even appliedCheck the actual response in server logs (App Service Log Stream) rather than trusting the browser console's CORS message alone

9. Quick Reference

CategoryItemDetail
ConceptAuthNWho you are — failure is 401
ConceptAuthZWhat you're allowed to do — failure is 403
JWTHeader.Payload.SignatureBase64Url-encoded, signature verified against issuer's public key
JWTClaims are readable, not secretNever put sensitive data in a JWT payload
OAuth2Authorization Code flowHuman login through a browser
OAuth2Client Credentials flowMachine-to-machine, no human involved
Entra External IDFull schema URL claimsUse ClaimTypes.* constants, not raw short names
AuthorizationRBACCoarse-grained, role membership only
AuthorizationClaims-basedFine-grained, can scope by tenant/resource
StoragehttpOnly cookieResists XSS, needs CSRF protection
StoragelocalStorageSimple, vulnerable to XSS token theft
LifecycleRefresh tokenSilently renews an expired access token

Part of the Traxs Engineering Handbook — Volume 3: Modern Development. Companion chapters in this volume: Docker Fundamentals, Docker Compose, REST APIs, JSON & YAML, Postman Guide.