Auth guard in Angular with .NET Identity and Refresh tokens

Introduction
An HTTP interceptor that attaches a bearer token and refreshes it on a 401 (covered in the companion .NET Identity + Angular video) handles API calls. It does nothing for navigation - without a guard, typing a protected route's URL directly, or clicking a stale bookmark, renders the page first and only fails once the first API call comes back unauthorized. This video adds the piece that closes that gap: an Angular route guard that checks authentication before the route ever activates.
🎬 Watch the full video here:
A functional guard, not a class
Modern Angular route guards are plain functions, not classes implementing CanActivate:
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService)
const router = inject(Router)
if (authService.isLoggedIn()) {
return true
}
router.navigate(['/login'], { queryParams: { returnUrl: state.url } })
return false
}
inject() works because a guard function still runs in an injection context when Angular's router invokes it. Wiring it up is a one-line addition per route:
{ path: 'dashboard', component: DashboardComponent, canActivate: [authGuard] }
What "logged in" actually checks
isLoggedIn() isn't just "does a token exist in storage" - a token can exist and be expired. A more honest check decodes the JWT's exp claim and compares it to the current time:
isLoggedIn(): boolean {
const token = localStorage.getItem('accessToken');
if (!token) return false;
const expiry = JSON.parse(atob(token.split('.')[1])).exp;
return expiry * 1000 > Date.now();
}
This is a client-side convenience check only - it decides whether to show the route, not whether the API will accept the request. The server-side [Authorize] on the .NET Identity endpoints is the actual security boundary; the guard's job is UX, not enforcement.
Refreshing before the guard gives up
A token that expired thirty seconds ago doesn't have to mean an immediate redirect to login. The guard can attempt a silent refresh first, the same refresh token flow the HTTP interceptor uses, and only fall back to /login if that fails too:
export const authGuard: CanActivateFn = async (route, state) => {
const authService = inject(AuthService)
const router = inject(Router)
if (authService.isLoggedIn()) return true
const refreshed = await firstValueFrom(authService.refreshToken()).catch(() => false)
if (refreshed) return true
router.navigate(['/login'], { queryParams: { returnUrl: state.url } })
return false
}
This is the detail that makes the difference between "user reopens a tab after lunch and gets bounced to login" and "user reopens a tab and it just works," because the refresh token (typically valid for days) is still good even though the access token expired an hour ago.
The returnUrl round trip
Passing state.url as a query param and reading it back on the login page after a successful sign-in returns the user to where they were actually headed, not just a generic dashboard:
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/dashboard'
this.router.navigateByUrl(returnUrl)
Small detail, but it's the difference between a route guard that feels invisible and one that visibly interrupts the user's flow.
Common pitfalls
- Checking only "does a token exist," not expiry. An expired token in storage still reads as "logged in" without a real check.
- Guarding routes but not also protecting the API. The guard is UX;
[Authorize]on the backend is the real boundary. - Losing the destination on redirect. Always carry
returnUrlthrough to the login page. - Not attempting a silent refresh before redirecting. Forces a full re-login for a token that's merely stale, not truly invalid.
- Forgetting
canActivateChildfor nested routes that should inherit the same guard.
Key Takeaways
- Angular route guards are functions (
CanActivateFn) that run in an injection context viainject(). - A guard checks the JWT's expiry client-side for UX; the API's
[Authorize]remains the actual security boundary. - Attempt a silent refresh-token exchange before redirecting to login, since the refresh token often outlives the access token by hours or days.
- Carry the attempted destination through to the login page via a
returnUrlquery param.
Get the Full Source Code
The complete runnable solution - the Angular app with the functional guard and refresh-aware AuthService, and the .NET Identity + JWT backend it talks to - is available to Patreon supporters. If you want to drop the guard into your own app instead of rebuilding it from the walkthrough above, you can find it on Patreon.