Route guards are one of the most misunderstood features in Angular, because their name sounds like security. A canActivate guard that checks user.role === 'admin' really does stop the router from showing the admin page. What it can’t do is stop anyone from calling the admin API, reading the admin page’s code, or flipping whatever flag the guard reads. All of that happens in a browser the user fully controls.
So the interview question “how do you secure an admin page in Angular?” has a two-part answer. Guards handle the experience: don’t show people screens they can’t use, send them to login, keep the URL sensible. The server handles authorization: every request is checked against the authenticated user, regardless of what the client thinks. Candidates who give only the first half are the reason the question gets asked.
The guard examples below were run through Angular 20.3’s RouterTestingHarness in Node, and the bundle inspection used ng build from the Angular 20.3 CLI. Angular 19 behaves the same.
Guards as navigation UX
Functional guards are plain functions that run in an injection context. They return true to allow, false to block, or a UrlTree (or, since Angular 18, a RedirectCommand) to redirect:
export const adminGuard: CanActivateFn = (route, state) => {
const user = inject(Auth).user();
if (user?.role === 'admin') return true;
return inject(Router).createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
export const routes: Routes = [
{ path: 'login', component: Login },
{ path: 'admin', canActivate: [adminGuard], component: AdminPanel },
];With no user signed in, navigating to /admin logged:
navigate /admin
canActivate admin? user=none
-> /login?returnUrl=%2Fadmin shows <login>That’s good UX. The user lands on the login page, and after signing in you can send them back to returnUrl. Return a UrlTree rather than calling router.navigate() inside the guard; the router handles the redirect as part of the same navigation instead of starting a competing one.
One detail worth validating: returnUrl comes from the URL, so a crafted link could set it to https://evil.example. Before navigating to it after login, check that it starts with / and isn’t //, or you’ve built an open redirect.
canMatch: choosing between routes
canMatch runs earlier, while the router is deciding which route config matches the URL. If it returns false, the router moves on and tries the next route with the same path. That lets you serve different components on one URL:
{ path: 'home', canMatch: [isAdmin], loadComponent: () => import('./admin-home') },
{ path: 'home', component: UserHome },The run showed both sides:
navigate /home (viewer)
canMatch admin? false
-> /home shows <user-home>
navigate /home (admin)
canMatch admin? true
loading admin-home chunk
-> /home shows <admin-home>The admin chunk loaded only for the admin. Download timing is the other practical difference between the two guards. With loadChildren, the router fetches the child routes while matching, which happens before canActivate runs; a test with a guard that always returned false logged load b (loadChildren) and then guard b. With loadComponent, a failing canActivate did stop the load. canMatch runs before either kind of loading, so it prevents the download for users who don’t need it. It saves bandwidth. It doesn’t hide anything.
Why a hidden chunk isn’t a secret
To see what a guarded lazy route actually protects, consider a small app with an admin component containing a “Delete all users” button and an API path, behind a canMatch guard reading localStorage. Building it with ng build prints:
Lazy chunk files | Names | Raw size
chunk-EGR3NXUC.js | admin | 369 bytesThat file sits in dist/ next to everything else and is served to anyone who requests it. Its name isn’t secret either. The main bundle contains the route config, minified but readable:
{path:"admin",canMatch:[Us],loadComponent:()=>import("./chunk-EGR3NXUC.js")}And the chunk contains the admin template text and the endpoint /api/admin/users/purge. Anyone can fetch it with a browser or curl without ever passing the guard. They can also skip the guard entirely: open DevTools, run localStorage.setItem('role', 'admin'), and navigate. If the guard reads a decoded JWT, they can edit the code in the Sources panel or override the response. The browser is their machine.
None of that is a bug in Angular. It’s what client-side code means. Treat everything in the bundle (templates, endpoints, feature-flag names, validation rules) as public information.
Where authorization actually lives
The admin page calls an API. That API is the security boundary:
// Express, illustrative: the check uses the verified token, not anything the client claims
app.post('/api/admin/users/purge', requireAuth, (req, res) => {
if (!req.user.roles.includes('admin')) {
return res.status(403).json({ error: 'forbidden' });
}
// ...
});The server verifies the session cookie or access token signature, derives the user and their permissions from it (or from its own database), and checks them for every request. It never trusts a role field in the request body, a header the client sets, or the fact that the request “came from the admin page”.
Data-level checks matter as much as role checks. GET /api/orders/123 must confirm that order 123 belongs to the requesting user, not just that they’re logged in. Changing an id in a URL is the most common authorization bug in real applications, and no route guard can stop it, because the attacker doesn’t need your Angular app to send the request.
On the client, handle the server’s answer gracefully. An HTTP interceptor that turns a 401 into “please sign in again” and a 403 into a friendly “you don’t have access” page keeps the UX consistent when the guard’s picture of the user is out of date (their role was revoked five minutes ago, for instance). See the note on HTTP interceptors for that pattern.
Guards that are still worth writing
None of this makes guards pointless. Good uses:
- Redirect unauthenticated users to login with a return URL.
- Hide routes the user can’t use, so they don’t see a page full of 403 errors.
canDeactivateto warn about unsaved changes before leaving a form.canMatchto choose a variant of a page, or to avoid downloading a large feature chunk for users who can’t use it.
Keep guards fast. They run on every navigation to the route, and an HTTP call inside a guard delays the navigation the same way a resolver does.
Interview practice
Your team’s Angular app has an /admin/reports page protected by a guard that reads isAdmin from a decoded JWT in localStorage. A penetration tester reports they downloaded every user’s salary report. The team says that’s impossible because the route is guarded. Who’s right, and what would you change?
Answer
The tester is right. The guard only controls what the Angular router renders in the tester’s own browser. The tester didn’t need the page at all: the reports endpoint’s URL is in the lazy chunk (which any client can download), and they can call it directly with their own valid token. If the API checks only that the token is valid, not that the user is an admin, it returns the data. They could also have edited localStorage or patched the guard in DevTools, since a decoded JWT on the client is just text the user can change; only the server can verify the signature.
The fix is on the server. The reports endpoint must verify the token’s signature, read the user’s roles from the verified claims or from the database, and return 403 if the user isn’t allowed, for every request. If reports are scoped (a manager sees only their team), the query must also filter by the authenticated user’s scope, not by an id the client sends.
On the client, I’d keep the guard for UX, switch it to canMatch if the admin area is a large lazy chunk, and add an interceptor that sends a 403 to an “access denied” page. I’d also say plainly in the review that the guard was never a security control, so nobody adds the next sensitive page with the same assumption.