.NET 8 Angular Authentication with Identity and Refresh Tokens

Introduction
.NET 8 added a set of ready-made Identity API endpoints - /register, /login, /refresh and more - so you no longer have to hand-write registration, password hashing, and token issuance for a standard username/password API. This video connects those endpoints to an Angular front end and builds the piece that makes the experience seamless: an HTTP interceptor that notices a 401, refreshes the token in the background, and replays the request the user never knew failed.
The access token expiration is set to one minute on purpose, so the refresh flow is easy to trigger and watch on camera.
🎬 Watch the full video here:
The backend is almost no code
builder.Services.AddDbContext<DemoDbContext>(o => o.UseSqlServer(connectionString));
builder.Services.AddIdentityApiEndpoints<IdentityUser>()
.AddEntityFrameworkStores<DemoDbContext>();
builder.Services.ConfigureAll<BearerTokenOptions>(o =>
o.BearerTokenExpiration = TimeSpan.FromMinutes(1));
var app = builder.Build();
app.MapIdentityApi<IdentityUser>();
AddIdentityApiEndpoints plus MapIdentityApi gives you the full set of auth endpoints against an EF Core IdentityDbContext. /login returns an accessToken, a refreshToken, and an expiresIn. /refresh takes a refresh token and returns a fresh pair. A protected endpoint is just .RequireAuthorization():
app.MapGet("/get-products", () => /* ... */).RequireAuthorization();
CORS must name the Angular origin explicitly (WithOrigins("http://localhost:4200")), and app.UseCors(...) goes before the endpoint mapping.
Where the Angular app stores the tokens
On successful login, the AuthService keeps the two tokens in different places:
localStorage.setItem('accessToken', response.accessToken)
document.cookie = `refreshToken=${response.refreshToken};`
This is the demo's choice and it is worth being honest about the trade-off. localStorage is readable by any script on the page, so a cross-site scripting bug exposes the token - the widely recommended alternative is to keep tokens out of JavaScript entirely by having the server set an HttpOnly cookie. The localStorage approach is simpler to wire against MapIdentityApi and common in tutorials; for production, prefer HttpOnly cookies plus CSRF protection.
The JWT interceptor: attach the token
An Angular HttpInterceptor clones every outgoing request and adds the bearer header when the user is logged in:
if (this.authService.isLoggedIn()) {
request = request.clone({
setHeaders: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` },
})
}
return next.handle(request)
Requests in Angular are immutable, which is why you clone with the new header rather than mutating. Now no component or service ever thinks about the token - it is attached centrally.
The error interceptor: refresh and retry
This is the interesting part. A second interceptor watches responses for a 401:
catchError((error) => {
if (error.status === 401 && !request.url.includes('/login')) {
return this.handle401Error(request, next)
}
return throwError(() => error)
})
handle401Error calls authService.refreshToken(), and on success uses switchMap to re-issue the original request with the new token attached:
return this.authService.refreshToken().pipe(
switchMap(() => next.handle(this.addToken(request))),
catchError((err) => {
this.authService.logout()
return throwError(() => err)
})
)
The user clicks something, the minute-old token is rejected, the app silently gets a new one and retries, and the click just works. If the refresh also fails (the refresh token itself expired), the app logs out and lets the error propagate to the login screen.
The !request.url.includes('/login') guard matters: a 401 from /login means "wrong password", not "expired token", and must not trigger a refresh loop.
The gap in the demo's refresh handling
The interceptor as written handles one request failing at a time cleanly. If several requests fire at once and all get a 401, each triggers its own refreshToken() call - a small race that issues multiple refreshes and can invalidate tokens depending on server rotation behavior. The production-grade version holds a single in-flight refresh (a BehaviorSubject / shared observable) that all concurrent 401s wait on. Worth knowing before you copy the pattern into a busy app.
Common pitfalls
AllowAnyOrigin()with credentials. Name the Angular origin withWithOrigins.- Tokens in
localStoragefor production. Prefer HttpOnly cookies; at minimum understand the XSS exposure. - Refreshing on a
401from/login. Guard against it or you get a confusing loop on a bad password. - Concurrent refresh calls. Share one in-flight refresh across simultaneous
401s. - Mutating the request instead of cloning. Angular
HttpRequestis immutable. - Forgetting to register both interceptors in the providers with
multi: true.
Key Takeaways
- .NET 8's
AddIdentityApiEndpoints+MapIdentityApiprovides/register,/login,/refreshagainst an EF Core Identity store with almost no code. /loginreturns an access token and a refresh token; protect endpoints with.RequireAuthorization().- A JWT interceptor clones each request to attach the bearer token centrally.
- An error interceptor catches
401, refreshes the token, and retries the original request so the user sees no interruption. - Guard the refresh logic against
401s from/loginand against concurrent refreshes. - The demo stores tokens in
localStorage; production should prefer HttpOnly cookies with CSRF protection.
Get the Full Source Code
The complete runnable solution - the .NET 8 API with Identity endpoints and a protected route, plus the Angular app with the auth service and both interceptors - is available to Patreon supporters. If you want to log in and watch the silent token refresh happen instead of rebuilding it from the walkthrough above, you can find it on Patreon.