OWASP Top 10 for Angular (2025 Edition)
How each 2025 risk shows up in a real Angular app, how to test for it, and how to fix it.

Hi there! I'm Mo' Claudius, a Software Developer with over 9 years of experience specializing in web development. I hold a Master's degree in Software Engineering, and I have a passion for working with Angular and Ruby on Rails. My expertise lies in creating efficient, user-friendly, and scalable web applications.
When I'm not coding, I enjoy practicing kickboxing (savate) and dancing (kizomba). These hobbies keep me active, energized, and inspired to bring creativity and innovation to my work.
As a member of the Hashnode community, my goal is to share my knowledge and experience with fellow developers in a "knowledge potluck" fashion. I believe that through collaboration and the exchange of ideas, we can all grow and improve in our respective fields.
Feel free to connect with me, and let's learn from each other while creating amazing things together!
OWASP Top 10 for Angular (2025 Edition)
For each risk: how it actually happens in an Angular app, a quick test to check whether your app has it, and the fix.
Introduction
The OWASP Top 10 is the industry's shared list of the most critical web security risks, built from real breach data. The 2025 edition (final, November 2025) is the current reference and supersedes the 2021 list.
If you learned the 2021 order, here's what changed: SSRF was folded into Broken Access Control, "Vulnerable and Outdated Components" grew into the broader Software Supply Chain Failures, and there's a new entry, Mishandling of Exceptional Conditions, for code that fails open when something goes wrong. A few categories were renamed and most shifted position.
Angular ships with solid security defaults: it escapes interpolated values, sanitizes bound HTML, and gives you guards, interceptors, and a hardened build pipeline. But defaults only protect you if you don't work around them, and several OWASP categories live entirely outside what a front-end framework can control. This post goes through all ten 2025 categories, each one grounded in a concrete Angular scenario you can reproduce, test, and fix today.
How to read this post. Every section follows the same loop:
- How it happens - a realistic Angular scenario, with code.
- Test it - something you can run in a few minutes to see if your app is exposed.
- Fix it - the Angular-specific countermeasure.
You need a working Angular app (v16+; examples use standalone components) and your browser's DevTools. Let's go.
A01:2025 Broken Access Control
How it happens
This is the most common serious issue I see in Angular codebases: authorization logic that lives only in the browser. A route guard checks a role from localStorage, the admin menu hides for non-admins, and everyone assumes the API is protected because the UI never links to it.
// auth.guard.ts - looks secure, isn't
export const adminGuard: CanActivateFn = () => {
const role = localStorage.getItem('user_role');
if (role === 'admin') return true;
return inject(Router).createUrlTree(['/forbidden']);
};
// admin.component.ts
export class AdminComponent {
private http = inject(HttpClient);
users = toSignal(
this.http.get<User[]>('/api/admin/users'),
{ initialValue: [] }
);
}
The guard improves UX, but the browser is the attacker's machine. They can flip localStorage, disable the guard, or skip your app entirely and call the API with curl.
SSRF now lives here. The 2025 edition folded Server-Side Request Forgery into this category, which makes sense: it's the server failing to control what a request is allowed to reach. Angular apps often supply the trigger, any feature where the user gives a URL and the server fetches it:
// avatar.component.ts - sends a user-supplied URL to the backend
importFromUrl(url: string) {
return this.http.post('/api/profile/avatar-from-url', { url });
}
An attacker enters http://169.254.169.254/latest/meta-data/ (cloud instance metadata) or an internal admin URL. The server, sitting inside your network perimeter, fetches it and returns the result. Your Angular form was the delivery mechanism.
Test it
- Open your app, log in as a normal user.
- In DevTools console:
localStorage.setItem('user_role', 'admin'), then navigate to/admin. - If the admin view renders, your UI trusts the client. Now the real test, bypassing the app completely:
curl -i https://your-api.com/api/admin/users \
-H "Authorization: Bearer <a normal user's token>"
If that returns 200 with data instead of 403, your access control is broken regardless of what the UI does.
- For the SSRF angle: find any "import from URL", link preview, or webhook-test feature and enter
http://169.254.169.254/latest/meta-data/. If the app returns metadata or internal content, the endpoint fetches arbitrary URLs.
Fix it
Enforce authorization on the server for every endpoint, on every request. The guard stays, but only as a UX convenience. Get roles from a server-verified source (a signed token or a /me endpoint), never from a value the client can write:
// Better: role comes from the server, verified per request
export const adminGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.currentUser().pipe(
map(user => user.role === 'admin' ? true : router.createUrlTree(['/forbidden']))
);
};
For SSRF, the fix is server-side: validate user-supplied URLs against an allowlist of schemes and hosts, block private IP ranges, and never follow redirects to unvalidated hosts. Client-side URL validation is good UX, not a security boundary.
Rule of thumb: if removing the front end changes what an attacker can access or reach, the control was never real.
A02:2025 Security Misconfiguration
How it happens
The Angular CLI ships secure defaults, but production deployments undo them quietly. The two I encounter most: source maps shipped to production, exposing your original TypeScript to anyone who opens DevTools, and missing security headers because the static host was never configured.
// angular.json - production configuration
"production": {
"sourceMap": true, // ships your .ts source to every visitor
...
}
Test it
- Open your production site, DevTools > Sources. If you can browse
webpack://and read your original.tsfiles, your source maps are public. Attackers use them to find API endpoints, hardcoded secrets, and logic flaws without guessing. - Check your security headers:
curl -sI https://your-site.com | grep -iE "content-security-policy|x-content-type-options|strict-transport|x-frame-options"
If that returns nothing, your app has no clickjacking, MIME-sniffing, or XSS policy at all.
Fix it
// angular.json
"production": {
"sourceMap": false,
...
}
And set headers at the hosting layer. For example, with Netlify (_headers file) or nginx:
Content-Security-Policy: default-src 'self'; script-src 'self'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000; includeSubDomains
Referrer-Policy: strict-origin-when-cross-origin
Add a CI check that fails the build if production source maps are enabled or if the deployed headers are missing. Misconfiguration is a deployment problem, so fix it in the deployment pipeline, not in developer memory.
A03:2025 Software Supply Chain Failures
How it happens
This is the 2025 edition's big expansion: the old "Vulnerable and Outdated Components" grew to cover the whole chain from npm install to production. An Angular app pulls in hundreds of transitive dependencies, and the build itself runs third-party code: custom builders and schematics in angular.json execute with full access to your machine at build time.
// package.json - pinned two years ago, never updated
"dependencies": {
"@angular/core": "16.2.0",
"lodash": "4.17.20"
}
// angular.json - who wrote this builder?
"architect": {
"build": { "builder": "some-unreviewed-package:custom-build" }
}
The usual story: the project was scaffolded two years ago, ng update was never run, CI runs npm install instead of npm ci (so the tested tree isn't the shipped tree), and nobody vetted the builder a tutorial told them to add. One compromised or typosquatted package anywhere in that chain can inject code into your production bundle.
Test it
- Run this in your project root right now:
npm audit --audit-level=high
npm audit maps your exact dependency tree against the GitHub Advisory Database and tells you which packages have known vulnerabilities, how severe they are, and which version fixes them. For the full picture:
npx ng update
- Check your CI config: does it run
npm ci(deterministic, fails on lockfile drift) ornpm install(resolves fresh, non-deterministic)? Ispackage-lock.jsoncommitted? Rungit ls-files | grep package-lockto confirm. - Open
angular.jsonand list every custom builder and schematic. For each one, can you name who maintains it and when you last updated it?
Fix it
- Run
ng update @angular/core @angular/clion a schedule, not when something breaks. Minor versions are low-risk; major versions deserve a branch and a test run. - Put
npm audit --audit-level=highin CI so a PR introducing a vulnerable dependency fails the build, and make CI usenpm ci, nevernpm install. - Enable Dependabot or Renovate for automated update PRs, and review them weekly.
- Treat builders and schematics like dependencies: pin them, update them, and don't add one from a tutorial without checking its source.
- Commit your
package-lock.json. Without it, every install resolves fresh versions and your "tested" build is fiction.
You ship your supply chain. If you can't list what's in the app, how it was built, and when each piece was last updated, you don't know what you're deploying.
A04:2025 Cryptographic Failures
How it happens
Angular doesn't encrypt your database, but the front end is where crypto failures become visible: tokens and PII sitting in localStorage in plain text, or an API URL that quietly uses http:// in one environment file.
// environment.prod.ts - spot the problem
export const environment = {
production: true,
apiUrl: 'http://api.myapp.com' // credentials in clear text
};
// auth.service.ts
login(creds: Credentials) {
return this.http.post<TokenResponse>(`${environment.apiUrl}/login`, creds)
.pipe(tap(res => localStorage.setItem('auth_token', res.token)));
}
Anyone on the network path reads the login request, and anyone with five seconds of DevTools access reads the token.
Test it
- Log in to your app, open DevTools > Application > Local Storage.
- If you can read a JWT or personal data in plain text, that's the failure. Paste the token into jwt.io to see exactly what it leaks.
- Open the Network tab, filter for your API domain. If any request shows
http://instead ofhttps://, credentials and tokens are traveling unencrypted.
Fix it
- Serve everything over HTTPS and enforce it: HSTS headers on the host, and fail the build if an environment file contains
http://for an API URL. - Stop putting tokens in web storage. Use
HttpOnly,Secure,SameSitecookies set by the server, and let the browser attach them automatically:
// interceptor becomes almost unnecessary - the browser sends the cookie
export const credentialsInterceptor: HttpInterceptorFn = (req, next) =>
next(req.clone({ withCredentials: true }));
localStorage is readable by any script on the page, which makes it the worst place for secrets. Treat it as a public bulletin board.
The subtler failure: secrets in the bundle
There's a second crypto failure I see constantly in Angular apps, and it survives code review because it looks like configuration:
// environment.ts - this file ships to every visitor
export const environment = {
production: false,
mapsApiKey: 'AIza...',
internalServiceToken: 'sk-live-...' // a real secret, now public
};
Everything in environment.*.ts is compiled into your JavaScript bundle. "Environment" here doesn't mean "server-side", it means "baked into the file the browser downloads". Any API key, token, or secret in those files is public the moment you deploy.
Test it: build for production and search the output for your own key formats:
ng build --configuration production
grep -ro 'sk-live-[A-Za-z0-9]*' dist/ | head -5
grep -ro 'AIza[A-Za-z0-9_-]*' dist/ | head -5
If your keys show up, they're in the bundle. (dist/ is a public artifact; assume attackers read it.)
Fix it: never put secrets in environment files. Anything the browser needs and the world may see (a publishable key) is fine; anything sensitive stays on a server. The pattern is a tiny backend proxy: the Angular app calls your own API, your API attaches the secret server-side and forwards the request. Environment files are public by design, treat them that way.
A05:2025 Injection
How it happens
Angular's template engine escapes interpolated values by default, which kills most XSS. The vulnerability appears the moment someone reaches for bypassSecurityTrustHtml to render user content, for example blog comments or rich-text bios:
// comments.component.ts
export class CommentsComponent {
private sanitizer = inject(DomSanitizer);
// "We need to render the user's formatted comment"
render(comment: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(comment);
}
}
<div [innerHTML]="render(comment.body)"></div>
That single call switches off Angular's sanitizer for this content. If comment.body contains a script payload, it executes. (The 2025 edition keeps injection at the same scope: SQL, NoSQL, OS command, LDAP, and XSS.)
Test it
Submit this as a comment (or into any field rendered with innerHTML):
<img src=x onerror="alert('XSS in ' + location.hostname)">
If an alert pops up, you have stored XSS: the payload now lives in your database and fires for every visitor. Try it in staging first, obviously.
Fix it
Stay on the safe path. Interpolation ({{ value }}) and property binding are escaped automatically; keep them. If you genuinely need HTML rendering:
// Sanitize instead of bypassing - strips scripts, keeps formatting
safeComment(comment: string): SafeHtml {
return this.sanitizer.sanitize(SecurityContext.HTML, comment) ?? '';
}
And reserve bypassSecurityTrustHtml for content your own build pipeline produced (like compiled markdown from your CMS), never for user input. Validate on the server too: client-side sanitization is a courtesy, server-side validation is the control.
Going further: Trusted Types
Sanitization is a code convention; Trusted Types makes it a browser-enforced rule. It's a browser API that locks down DOM XSS sinks (innerHTML, outerHTML, eval, and friends) so they only accept typed TrustedHTML values, never raw strings. Angular has shipped built-in support since v11.
Enable it with a CSP header:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types angular angular#bundler;
Angular registers its own policies: angular for the framework's internal DOM operations, angular#bundler for lazy-loaded chunk injection. The telling detail is the policy you leave out: angular#unsafe-bypass is the policy behind every bypassSecurityTrustHtml call. Omit it from the header and any bypass in your codebase throws at runtime instead of silently executing.
Test it: add the header in development (via the headers option under serve in angular.json, or a local proxy), then click through the app. Every dangerous sink assignment now throws a loud console error, which means the exact mistake from the scenario above gets caught the day it's written, not the day it's exploited. (Enforcement is strongest in Chromium-based browsers; others are still catching up.)
This is the strongest XSS defense available in the browser: even if someone bypasses sanitization in code, the browser refuses to render the string.
A06:2025 Insecure Design
How it happens
Insecure design is not a coding bug, it's a flow the attacker was never supposed to reach. Classic Angular example: a multi-step password reset where the front end tracks progress in a service or route state.
// reset-flow.service.ts - the client decides which step you're on
@Injectable({ providedIn: 'root' })
export class ResetFlowService {
step = signal(1); // 1 = email, 2 = code, 3 = new password
advanceTo(step: number) { this.step.set(step); }
}
// step-3 route has no guard at all, or a guard reading the same signal
const routes: Routes = [
{ path: 'reset/step-3', component: NewPasswordComponent }
];
The design assumes users walk the steps in order. They don't have to.
Test it
- Start the reset flow, complete step 1 (enter email).
- Instead of completing step 2, paste the step-3 URL directly into the address bar.
- If the "set new password" form renders and the submission succeeds, the workflow state is trusted from the client. The verification step was decoration.
Fix it
Move workflow state to the server. Each step should require a server-issued, single-use token from the previous step:
// Guard that asks the server, not a local signal
export const resetStepGuard: CanActivateFn = (route) => {
const http = inject(HttpClient);
const router = inject(Router);
const token = route.queryParamMap.get('t');
return http.get<{ valid: boolean }>(`/api/reset/verify-step?token=${token}`)
.pipe(map(res => res.valid ? true : router.createUrlTree(['/reset'])));
};
Design reviews catch this class of issue better than any scanner: for every multi-step flow, ask "what happens if the user starts at the last step?"
A07:2025 Authentication Failures
How it happens
The Angular app logs the user in, receives a JWT, and stores it in localStorage so an interceptor can attach it to every request:
// token.interceptor.ts - the standard tutorial pattern
export const tokenInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem('auth_token');
return token
? next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }))
: next(req);
};
This works, and it's also the setup behind most session-theft stories. Any XSS vulnerability (see A05) becomes full account takeover: the attacker's script reads localStorage and exfiltrates the token. Add tokens that never expire and there's no session to kill after a breach.
Test it
- Log in, open DevTools console, run:
localStorage.getItem('auth_token')
- If a token comes back, copy it to jwt.io. Check the
expclaim: if it's weeks out (or missing), a stolen token is useful for weeks. - The uncomfortable follow-up: combine this with the XSS test from A05. If both succeed, one injected script owns every session.
Fix it
Store the session in an HttpOnly, Secure, SameSite=Strict cookie set by the server. JavaScript can't read HttpOnly cookies, so XSS can't steal the session token:
// The interceptor shrinks to one line - the browser attaches the cookie
export const credentialsInterceptor: HttpInterceptorFn = (req, next) =>
next(req.clone({ withCredentials: true }));
Pair it with short-lived access tokens and rotating refresh tokens on the server, so even a leaked token has a short shelf life. The front end's job is simple: never hold the secret itself.
A08:2025 Software or Data Integrity Failures
How it happens
Your index.html loads a third-party script straight from a CDN, no questions asked:
<script src="https://cdn.example.com/analytics/v2/tracker.js"></script>
If that CDN account is compromised (or the domain lapses and gets re-registered), the replacement script runs with full access to your page: it can read the DOM, hook into your Angular app, and exfiltrate anything the user types. You wouldn't know; your code didn't change.
Test it
- View your production page source (or DevTools > Elements) and find every
<script>and<link>pointing at an external host. - If any of them lacks an
integrityattribute, that resource is loaded on pure trust. List them; that's your exposure surface.
Fix it
Pin external resources with Subresource Integrity (SRI) hashes. Generate one for each file:
curl -s https://cdn.example.com/analytics/v2/tracker.js \
| openssl dgst -sha384 -binary | openssl base64 -A
<script src="https://cdn.example.com/analytics/v2/tracker.js"
integrity="sha384-<hash from above>"
crossorigin="anonymous"></script>
If the file changes by a single byte, the browser refuses to load it. (For the dependency side of integrity, see A03: commit the lockfile and build with npm ci.)
Don't trust your own serialized data
Integrity failures aren't only about third-party code. Many Angular apps serialize state to localStorage and trust it on the next boot:
// app initializer - trusts whatever is in storage
export function initUser(auth: AuthService) {
return () => {
const cached = localStorage.getItem('current_user');
if (cached) auth.setUser(JSON.parse(cached)); // role included, unverified
};
}
localStorage is user-editable by design. Anyone can open DevTools and rewrite the stored object before the app boots.
Test it: log in, then in DevTools console:
const u = JSON.parse(localStorage.getItem('current_user'));
u.role = 'admin';
localStorage.setItem('current_user', JSON.stringify(u));
location.reload();
If the UI now treats you as admin (and especially if any guard reads that cached role), your boot sequence trusts attacker-controlled data.
Fix it: revalidate with the server on boot (a /me call), and cache only an opaque session identifier, never the claims themselves. Anything the client stored can be rewritten; the server's answer is the only one that counts.
A09:2025 Security Logging and Alerting Failures
How it happens
Angular's default error handling writes to the console and nowhere else. In production, that means exceptions, failed API calls, and suspicious patterns (a burst of 401s, repeated guard rejections) evaporate the moment the user closes the tab. The average breach takes months to detect; missing client-side telemetry is one reason why. (The 2025 rename, from "Monitoring" to "Alerting", makes the point: logs nobody acts on are just storage costs.)
// What most apps effectively run in production: the default handler
// Errors go to console.error(). Nobody is watching the console.
Test it
- Open your production app, trigger an error (submit a form that 500s, or navigate to a broken route).
- Ask your team: where did that error go? If the answer is "the user's console" or a shrug, you have no client-side detection.
- Bonus test: make 20 failed login attempts in a row. Does anyone get alerted, or does the API just keep answering?
Fix it
Implement a custom ErrorHandler that ships sanitized errors to your logging pipeline:
@Injectable()
export class MonitoringErrorHandler implements ErrorHandler {
private http = inject(HttpClient);
handleError(error: unknown): void {
const payload = {
message: error instanceof Error ? error.message : String(error),
url: location.href,
timestamp: new Date().toISOString(),
// never send tokens, passwords, or full request bodies
};
// Fire and forget - logging must never break the app
this.http.post('/api/client-errors', payload).subscribe({
error: () => console.error(error)
});
}
}
// app.config.ts
providers: [
{ provide: ErrorHandler, useClass: MonitoringErrorHandler }
]
Scrub sensitive fields before sending, aggregate client logs with server logs in one place, and set alerts for patterns: spikes in 401/403 responses, repeated validation failures, unusual navigation sequences. Logging without alerting is just expensive storage.
A10:2025 Mishandling of Exceptional Conditions
How it happens
This is the 2025 edition's new entry, and it's uncomfortably common in Angular apps: code that fails open instead of closed. The classic is a route guard that treats an error as permission:
// admin.guard.ts - "don't block the user if the API hiccups"
export const adminGuard: CanActivateFn = () => {
const http = inject(HttpClient);
const router = inject(Router);
return http.get<User>('/api/me').pipe(
map(user => user.role === 'admin'
? true
: router.createUrlTree(['/forbidden'])),
catchError(() => of(true)) // API error? Let them in anyway.
);
};
The developer's intent was resilience: don't punish the user for a flaky network. The effect is that anyone who can make /api/me fail (offline mode, a blocked request, a proxy hiccup) walks into the admin area. Variations of the same mistake: a form that submits when the validation service throws, a payment component that retries a failed charge without idempotency and double-bills, an interceptor that silently swallows 403s and replays the request.
Test it
- Open your app and log in as a non-admin user.
- In DevTools > Network, right-click your user/profile endpoint and choose Block request URL (or go offline).
- Navigate to a guarded route. If you get in while the API is failing, the guard fails open.
- Generalize the test: for every
catchErrorin your guards, interceptors, and auth flows, ask what the fallback value permits.of(true),of([]), and empty defaults are the smell.
You can find candidates fast:
grep -rn "catchError" src/app --include="*.ts" | grep -iE "guard|auth|intercept"
Fix it
Default to closed. Errors in a security decision must deny, redirect to a safe error page, or retry visibly, never silently permit:
// Fail closed: on error, nobody gets in
export const adminGuard: CanActivateFn = () => {
const http = inject(HttpClient);
const router = inject(Router);
return http.get<User>('/api/me').pipe(
map(user => user.role === 'admin'
? true
: router.createUrlTree(['/forbidden'])),
catchError(() =>
of(router.createUrlTree(['/error'], {
queryParams: { reason: 'auth-check-failed' }
}))
)
);
};
Make it a code review rule: any catchError (or try/catch) on an auth, guard, or payment path gets a second look, and the fallback must be the least-privilege outcome. Resilience and security aren't opposites, but when they conflict, the error path must not be the permissive one.
Conclusion: the patterns behind the ten
Look across all ten and three themes repeat:
- The browser is hostile territory. Anything enforced only in Angular (guards, hidden buttons, local signals, client-side validation) is UX, not security. Every control that matters must also exist on the server.
- Don't fight the framework. Angular escapes, sanitizes, and secures by default. Nearly every injection and XSS story starts with a developer bypassing those defaults. When you reach for
bypassSecurityTrustHtmlor disable a CLI default, treat it as a security decision and document why. - Verify, don't assume. Every section above has a two-minute test. Run them against your app this week:
npm audit, the DevTools storage check, the header check, the guard-bypass check, the offline guard test. Most findings take an afternoon to fix.
Quick-reference checklist (2025 edition)
| # | Risk | The one-line fix |
|---|---|---|
| A01 | Broken Access Control | Authorize every request on the server; guards are UX only (SSRF lives here now) |
| A02 | Security Misconfiguration | No prod source maps; set security headers; enforce in CI |
| A03 | Software Supply Chain Failures | npm audit in CI; npm ci; vet builders; commit the lockfile |
| A04 | Cryptographic Failures | HTTPS everywhere; HttpOnly cookies for tokens; never ship secrets in the bundle |
| A05 | Injection | Never bypass Angular sanitization for user input |
| A06 | Insecure Design | Keep workflow state on the server; ask "what if they start at the last step?" |
| A07 | Authentication Failures | HttpOnly Secure cookies; short-lived, rotating tokens |
| A08 | Software or Data Integrity Failures | SRI hashes on CDN resources; revalidate serialized client data with the server |
| A09 | Logging and Alerting Failures | Custom ErrorHandler shipping scrubbed errors; alert on patterns |
| A10 | Mishandling of Exceptional Conditions | Fail closed: error paths must deny, never permit |
Security isn't a feature you add at the end; it's the sum of small decisions like these, made consistently. Pick the three tests most relevant to your app, run them today, and fix what you find. Your future self, the one responding to the 2 AM incident page, will thank you.



