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 answered | Who are you? | What are you allowed to do? |
| Happens | Once, at login (or per-request via a token proving a prior login) | On every action, potentially differently per action |
| Failure status | 401 Unauthorized | 403 Forbidden |
| Traxs example | Logging in via Azure Entra External ID | Whether 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:
| Part | Contains |
|---|---|
| Header | Which algorithm was used to sign it (e.g. RS256) |
| Payload | The 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 |
| Signature | Cryptographic 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:
| Flow | Used for |
|---|---|
| Authorization Code flow | A 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 flow | Machine-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
| Approach | How 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
| Storage | Risk |
|---|---|
localStorage | Accessible to any JavaScript running on the page — vulnerable to XSS: a single injected script can read and exfiltrate the token |
httpOnly cookie | Not 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
| Symptom | Likely cause | What to check |
|---|---|---|
401 on every request despite a seemingly valid login | Token not actually attached, or Authorization: Bearer <token> header malformed | Confirm 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 role | Reading a short claim name against a token using full schema URL claim names | Use ClaimTypes.Role (or the equivalent typed helper) instead of a raw string literal |
Token valid one moment, 401 moments later | Access token expired, refresh not implemented or failing silently | Confirm token exp claim and actual elapsed session time; check refresh token flow logs |
403 when the user insists they should have access | Role present, but a claims-based scoping check (like tenant match) is failing | Log 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 wrong | Confirm the API's configured issuer/authority matches exactly what issued the token |
Intermittent 401s under load, users complain of random logouts | Clock skew between servers affecting exp/nbf validation, or a load balancer routing to instances with inconsistent time sync | Check 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/403 | Browser reports a generic CORS failure when the real response was blocked before CORS headers were even applied | Check the actual response in server logs (App Service Log Stream) rather than trusting the browser console's CORS message alone |
9. Quick Reference
| Category | Item | Detail |
|---|---|---|
| Concept | AuthN | Who you are — failure is 401 |
| Concept | AuthZ | What you're allowed to do — failure is 403 |
| JWT | Header.Payload.Signature | Base64Url-encoded, signature verified against issuer's public key |
| JWT | Claims are readable, not secret | Never put sensitive data in a JWT payload |
| OAuth2 | Authorization Code flow | Human login through a browser |
| OAuth2 | Client Credentials flow | Machine-to-machine, no human involved |
| Entra External ID | Full schema URL claims | Use ClaimTypes.* constants, not raw short names |
| Authorization | RBAC | Coarse-grained, role membership only |
| Authorization | Claims-based | Fine-grained, can scope by tenant/resource |
| Storage | httpOnly cookie | Resists XSS, needs CSRF protection |
| Storage | localStorage | Simple, vulnerable to XSS token theft |
| Lifecycle | Refresh token | Silently 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.