JWT authentication in ASP.NET Core on this page is issuance, validation, and lifetimes. Refresh rotation is refresh token rotation. The Angular client is JWT interceptors.
Login ──► access JWT (short) + refresh (long, server-tracked)
│
▼
API validates iss/aud/key/exp on every request
│
▼
[Authorize] policies on sensitive paths
│
401 ──► SPA refresh ──► retry or logout
New to this → stay here. Angular interceptors → JWT interceptors. Route guards → auth guards. Refresh rotation → refresh token rotation. Interview prep → If an interviewer asks.
What "JWT auth" actually includes
Treat auth as a system, not a NuGet package:
- Identity issuance — who creates tokens, with what claims, signed how
- Token lifetime policy — access token TTL, refresh token rules, revocation
- Authorization — roles, policies, and resource-level checks on every sensitive path
- Client storage and transport — how the SPA holds tokens and attaches them to API calls
- Operational hygiene — secret rotation, HTTPS, logging without leaking credentials
Skip any one of those and you do not have production auth. You have a demo that returns 200 on /api/me.
JWT in a SaaS API checklist
A SaaS boilerplate JWT implementation is a login endpoint and a [Authorize] attribute. A SaaS API that survives a second tenant still needs this list — the rest of the article is how:
- Access JWT measured in minutes, not days
- Refresh rotation + server-side revocation (password reset, lockout, logout everywhere)
- Named policies (and resource/tenant checks) — not one
Adminrole for every write - Angular: one interceptor, one refresh path, httpOnly refresh cookie when you can
- Claims carry
sub+ tenant/org — not PHI the SPA does not need - Secrets in Key Vault / app settings — never committed
appsettings.json - 401 vs 403 distinguishable so the SPA knows whether to refresh or show "ask admin"
Deep dives: refresh rotation, HttpOnly refresh cookie, 401 vs 403.
JWT vs BFF + cookie
Think theme-park wristbands: the access JWT gets you through rides for a few hours; the refresh record at guest services issues a new wristband and can revoke a stolen pass. A 30-day JWT in localStorage is a wristband that never expires — taped to the guest's phone.
| Use JWT (short-lived access + rotated refresh) | Prefer BFF + httpOnly cookie session |
|---|---|
| Mobile app or partner API that cannot share a site cookie | First-party Angular SPA on the same origin |
| Multiple resource APIs behind one gateway | You want zero bearer token in localStorage |
| Existing Bearer contract across services | A boilerplate issued 30-day JWTs — that is the bug to fix |
Access tokens should live in minutes. Refresh tokens are server-tracked and revocable. If the SPA only needs “stay signed in,” rotation or a BFF beats a long-lived JWT in browser storage.
// SaaS API — short access JWT, validate everything
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwt.Issuer,
ValidateAudience = true,
ValidAudience = jwt.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Key)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1),
};
options.AddPolicy("ClinicRead", p =>
p.RequireClaim("tenant")
.AddRequirements(new SameClinicRequirement()));
API-side essentials in ASP.NET Core
Validate everything explicitly
Do not rely on framework defaults you have not read. In Program.cs (or your auth extension), configure:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = config["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = config["Jwt:Audience"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(config["Jwt:Key"]!)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
});
If issuer, audience, or signing key are wrong, I want the request to fail loudly in staging — not silently in production when a mobile client appears with a misconfigured build.
Prefer policies over bare [Authorize]
Roles alone get messy in SaaS products. I define named policies early:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ManageCatalog", policy =>
policy.RequireRole("Admin", "Merchandiser"));
options.AddPolicy("ViewPatientChart", policy =>
policy.RequireRole("Clinician")
.AddRequirements(new SameClinicRequirement()));
});
On Ecom_NET10, catalog mutations and order refunds map to different policies even when some users share a broad "Admin" label. In healthcare APIs, role plus resource scope is non-negotiable — a valid clinician token must not imply access to every patient record.
Keep secrets out of source control
Signing keys, client secrets, and connection strings belong in environment variables, Azure Key Vault, or your host's secret store — never in appsettings.json committed to Git. I document which values each environment needs in a table the client owns, not in the repository.
Token lifetimes and refresh strategy
Short-lived access tokens limit damage when one leaks. I commonly start around 15–30 minutes for SPA clients, then tune based on UX complaints and risk profile.
Refresh tokens need explicit rules:
- Rotation — issuing a new refresh token when the old one is used, invalidating the previous
- Storage — httpOnly secure cookies for refresh on many SPAs; avoid long-lived refresh tokens in
localStorage - Revocation — server-side invalidation on password change, admin lockout, or "log out everywhere"
CarBazaar's identity service centralizes issuance so auction and search APIs trust the same issuer. Smaller monoliths like Ecom_NET10 can expose /api/auth/refresh directly, but the contract is the same: one refresh path, one place that decides if the session is still valid.
Document the flow in one diagram for the client: login → access token → API call → 401 → refresh → retry or logout. That diagram has closed more scope arguments than any library list.
Angular client habits that prevent rework
Attach tokens in one interceptor
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = authStore.accessToken();
if (!token) return next(req);
return next(req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
}));
};
Every API call goes through the same path. Feature teams do not hand-copy headers into individual services.
Handle 401 with a single refresh/retry path
Multiple competing refresh handlers cause login loops — especially painful in healthcare portals where users lose half-entered forms. One service owns refresh; the interceptor awaits it once and retries the original request, or clears session and routes to login.
Storage tradeoffs (be honest with the client)
| Approach | Pros | Cons |
|---|---|---|
| Memory only | Smallest XSS window | Lost on full page refresh unless paired with refresh cookie |
| httpOnly refresh cookie + memory access token | Strong common pattern | Requires correct CORS and cookie flags |
| localStorage for both | Easy to implement | XSS can exfiltrate tokens |
I explain the tradeoff in writing. "Easy localStorage" is not free — it is a risk acceptance decision.
Security checklist before go-live
Run through this with the client's name on each line:
- HTTPS enforced everywhere, including staging
- Access token TTL documented and justified
- Refresh rotation and revocation tested manually
- Every sensitive endpoint has policy or resource check — spot-check with a lower-privilege test user
- CORS allows only known SPA origins — no wildcard with credentials
- JWT claims do not carry PHI or PII the SPA does not need
- Auth failures return consistent error shapes (401 vs 403 distinguishable)
- Logs never print bearer tokens or refresh tokens
- Password reset and lockout flows tested end to end
- Secret rotation procedure written (even if rotation is manual at first)
For healthcare-adjacent work, add: audit log entries for admin impersonation, role changes, and failed access to restricted records — without logging patient identifiers into generic application logs.
Common failures I see on freelance rescue work
Long-lived access tokens "because refresh was hard." Refresh is always harder than skipping it. So is explaining a breach.
Authorization only on controllers. Minimal APIs, SignalR hubs, and background job triggers need the same policies.
Copy-pasted JWT config from a tutorial. Issuer and audience mismatches between staging and production cause week-long mysteries.
Angular stores everything in localStorage. Fine for a hackathon. Not fine for a marketplace with seller payouts or a clinic admin panel.
No logout story. Client-side token deletion is not server-side revocation. Document what each logout button actually does.
Delivery tip: document the contract, not just the code
Handoff quality matters on freelance engagements. I deliver:
- Sequence diagram (login, refresh, logout)
- Table of claims and which policies consume them
- Environment variable list for JWT settings
- Test accounts per role with expected access matrix
Clients trust the system when they can verify it without reading Program.cs. That trust is what gets you phase two.
If the log is IDX10503: Signature validation failed (often with a misleading “no kid”), that is the IDX10503 article — not a missing [Authorize] attribute. If it is IDX10501: Unable to match key, the kid is missing from JWKS: IDX10501.
When to reach for IdentityServer / OpenIddict
Not every project needs a separate identity server on day one. I split identity out when:
- Multiple APIs or SPAs share the same user base (CarBazaar-style)
- Third-party clients need OAuth client credentials
- Centralized SSO across services is already a requirement
For a single API + single Angular admin, JWT baked into the API with clear refresh endpoints is often enough until the second consumer appears.
If an interviewer asks
"What must you validate on every JWT in ASP.NET Core?"
Strong answer: Issuer, audience, signing key, and lifetime — explicitly in TokenValidationParameters. Do not rely on defaults you have not read. Clock skew should be modest (about one minute), not five minutes that keep expired tokens alive.
"How long should access vs refresh tokens live?"
Strong answer: Access: minutes (5–15 for SPAs). Refresh: hours to days with server-side hashing, rotation, and family revocation. Long-lived access tokens because refresh was hard is a common breach pattern.
"Is JWT auth enough without refresh tokens?"
Strong answer: For kiosks or jobs with another session, maybe. For SPAs where users stay signed in, you need refresh rotation, httpOnly cookie refresh, or a BFF — each is a documented tradeoff, not an accident.