# OWASP Top 10 for Angular (2025 Edition)

# 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:

1. **How it happens** - a realistic Angular scenario, with code.
2. **Test it** - something you can run in a few minutes to see if your app is exposed.
3. **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.

```typescript
// 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']);
};
```

```typescript
// 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:

```typescript
// 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

1. Open your app, log in as a normal user.
2. In DevTools console: `localStorage.setItem('user_role', 'admin')`, then navigate to `/admin`.
3. If the admin view renders, your UI trusts the client. Now the real test, bypassing the app completely:

```bash
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.

4. 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:

```typescript
// 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.

```json
// angular.json - production configuration
"production": {
  "sourceMap": true,   // ships your .ts source to every visitor
  ...
}
```

### Test it

1. Open your production site, DevTools > **Sources**. If you can browse `webpack://` and read your original `.ts` files, your source maps are public. Attackers use them to find API endpoints, hardcoded secrets, and logic flaws without guessing.
2. Check your security headers:

```bash
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

```json
// 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.

```json
// package.json - pinned two years ago, never updated
"dependencies": {
  "@angular/core": "16.2.0",
  "lodash": "4.17.20"
}
```

```json
// 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

1. Run this in your project root right now:

```bash
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:

```bash
npx ng update
```

2. Check your CI config: does it run `npm ci` (deterministic, fails on lockfile drift) or `npm install` (resolves fresh, non-deterministic)? Is `package-lock.json` committed? Run `git ls-files | grep package-lock` to confirm.
3. Open `angular.json` and 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/cli` on 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=high` in CI so a PR introducing a vulnerable dependency fails the build, and make CI use `npm ci`, never `npm 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.

```typescript
// environment.prod.ts - spot the problem
export const environment = {
  production: true,
  apiUrl: 'http://api.myapp.com'   // credentials in clear text
};
```

```typescript
// 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

1. Log in to your app, open DevTools > **Application** > **Local Storage**.
2. 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.
3. Open the **Network** tab, filter for your API domain. If any request shows `http://` instead of `https://`, 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`, `SameSite` cookies set by the server, and let the browser attach them automatically:

```typescript
// 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:

```typescript
// 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:

```bash
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:

```typescript
// 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);
  }
}
```

```html
<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`):

```html
<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:

```typescript
// 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.

```typescript
// 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); }
}
```

```typescript
// 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

1. Start the reset flow, complete step 1 (enter email).
2. Instead of completing step 2, paste the step-3 URL directly into the address bar.
3. 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:

```typescript
// 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:

```typescript
// 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

1. Log in, open DevTools console, run:

```js
localStorage.getItem('auth_token')
```

2. If a token comes back, copy it to jwt.io. Check the `exp` claim: if it's weeks out (or missing), a stolen token is useful for weeks.
3. 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:

```typescript
// 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:

```html
<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

1. View your production page source (or DevTools > **Elements**) and find every `<script>` and `<link>` pointing at an external host.
2. If any of them lacks an `integrity` attribute, 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:

```bash
curl -s https://cdn.example.com/analytics/v2/tracker.js \
  | openssl dgst -sha384 -binary | openssl base64 -A
```

```html
<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:

```typescript
// 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:

```js
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.)

```typescript
// What most apps effectively run in production: the default handler
// Errors go to console.error(). Nobody is watching the console.
```

### Test it

1. Open your production app, trigger an error (submit a form that 500s, or navigate to a broken route).
2. 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.
3. 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:

```typescript
@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:

```typescript
// 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

1. Open your app and log in as a non-admin user.
2. In DevTools > **Network**, right-click your user/profile endpoint and choose **Block request URL** (or go offline).
3. Navigate to a guarded route. If you get in while the API is failing, the guard fails open.
4. Generalize the test: for every `catchError` in 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:

```bash
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:

```typescript
// 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:

1. **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.
2. **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 `bypassSecurityTrustHtml` or disable a CLI default, treat it as a security decision and document why.
3. **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.
