| name | angular-security |
| description | Angular-specific security patterns and vulnerability prevention. Use when auditing or securing Angular applications — covers built-in sanitizer, DomSanitizer bypass risks, CSP with Angular, HttpClient security, route guards, and template injection. |
| license | MIT |
| metadata | {"author":"pragma-frontend-security","version":"1.0","framework":"Angular 17+"} |
Angular Security Patterns
Security rules specific to Angular applications. Complements the shared frontend-security skill with Angular-specific APIs, built-in protections, and framework-specific attack vectors.
A-S1 — Angular's Built-in Sanitizer
Angular sanitizes values in templates automatically. This is your first line of defense — never bypass it without justification.
How It Works
Angular marks these contexts as unsafe and sanitizes:
- innerHTML → sanitizes HTML
- [src] → sanitizes URLs
- [href] → sanitizes URLs
- [style] → sanitizes CSS
NEVER Do
import { DomSanitizer } from '@angular/platform-browser';
constructor(private sanitizer: DomSanitizer) {}
this.sanitizer.bypassSecurityTrustHtml(userInput);
this.sanitizer.bypassSecurityTrustScript(userInput);
this.sanitizer.bypassSecurityTrustUrl(userInput);
this.sanitizer.bypassSecurityTrustResourceUrl(userInput);
this.sanitizer.bypassSecurityTrustStyle(userInput);
const template = `<div>${userInput}</div>`;
WHEN Bypass Is Acceptable (with safeguards)
@Pipe({ name: 'safeHtml', standalone: true })
export class SafeHtmlPipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}
transform(value: string): SafeHtml {
const clean = DOMPurify.sanitize(value, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
return this.sanitizer.bypassSecurityTrustHtml(clean);
}
}
Document Every Bypass
A-S2 — HttpClient Security
NEVER Do
const url = `${baseUrl}/users/${userId}`;
this.http.get(url);
this.http.get('https://third-party.com/api', { withCredentials: true });
intercept(req, next) {
console.log('Request:', req.body);
return next(req);
}
ALWAYS Do
const params = new HttpParams()
.set('search', userInput)
.set('page', '1');
this.http.get('/api/users', { params });
private sanitizePath(segment: string): string {
return segment.replace(/[^a-zA-Z0-9_-]/g, '');
}
@Injectable()
export class CsrfInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const csrfToken = this.getCsrfToken();
if (csrfToken) {
req = req.clone({
setHeaders: { 'X-CSRF-Token': csrfToken }
});
}
}
return next.handle(req);
}
private getCsrfToken(): string | null {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? null;
}
}
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
private readonly API_BASE = environment.apiUrl;
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
if (req.url.startsWith(this.API_BASE)) {
req = req.clone({ withCredentials: true });
}
return next.handle(req);
}
}
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
return next.handle(req).pipe(
catchError((error: HttpErrorResponse) => {
const userMessage = this.getUserFriendlyMessage(error.status);
if (!environment.production) {
console.error('HTTP Error:', error);
}
return throwError(() => new Error(userMessage));
})
);
}
}
A-S3 — Route Guards & Authorization
NEVER Do
canActivate(): boolean {
return !!localStorage.getItem('token');
}
<button *ngIf="isAdmin" routerLink="/admin">Admin Panel</button>
canActivate(): boolean {
const user = JSON.parse(localStorage.getItem('user')!);
return user.role === 'admin';
}
ALWAYS Do
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
export const roleGuard: CanActivateFn = (route) => {
const authService = inject(AuthService);
const router = inject(Router);
const requiredRoles = route.data['roles'] as string[];
return authService.validateSession().pipe(
map((session) => {
if (requiredRoles.some((role) => session.roles.includes(role))) {
return true;
}
return router.createUrlTree(['/unauthorized']);
}),
catchError(() => of(router.createUrlTree(['/login'])))
);
};
const routes: Routes = [
{
path: 'admin',
canActivate: [authGuard, roleGuard],
data: { roles: ['admin'] },
loadComponent: () => import('./admin/admin.component'),
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./dashboard/dashboard.component'),
},
];
A-S4 — Template Security
NEVER Do
@Component({
template: userProvidedTemplate,
})
<div [innerHTML]="apiResponse.htmlContent"></div>
<a [href]="userProvidedUrl">Link</a>
ALWAYS Do
<p>{{ userInput }}</p> <!-- Angular escapes this -->
<div [innerHTML]="trustedContent | safeHtml"></div>
@Pipe({ name: 'safeUrl', standalone: true })
export class SafeUrlPipe implements PipeTransform {
transform(url: string): string {
try {
const parsed = new URL(url, window.location.origin);
if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
return url;
}
} catch {}
return '#';
}
}
<a [routerLink]="['/user', userId]">Profile</a>
A-S5 — CSP with Angular
Angular requires unsafe-inline for styles by default (ViewEncapsulation). Mitigate with nonces.
{
"projects": {
"app": {
"architect": {
"build": {
"options": {
"security": {
"autoCsp": true
}
}
}
}
}
}
}
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy',
`default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}';`
);
next();
});
Angular Security Checklist
| Category | Check | Severity |
|---|
| Sanitizer | No bypassSecurityTrust* without DOMPurify + documentation | CRITICAL |
| Sanitizer | [innerHTML] only with sanitized content | HIGH |
| HttpClient | URL params via HttpParams, not string concatenation | HIGH |
| HttpClient | CSRF interceptor for state-changing requests | HIGH |
| HttpClient | Auth interceptor scoped to your API domain only | HIGH |
| HttpClient | Error interceptor sanitizes error messages | MEDIUM |
| Guards | Auth guard validates with server, not localStorage | CRITICAL |
| Guards | Role guard checks server-verified roles | CRITICAL |
| Guards | Backend enforces same authorization independently | CRITICAL |
| Templates | No dynamic template compilation from user input | CRITICAL |
| Templates | URLs validated before href binding | HIGH |
| Templates | Use routerLink over manual href construction | MEDIUM |
| CSP | autoCsp enabled (Angular 17+) | HIGH |
| CSP | Nonce-based script/style policy in production | HIGH |
| General | No console.log in production (use environment check) | MEDIUM |
| General | Lazy-loaded routes still protected by guards | HIGH |