Security by design in Angular

Security by design in Angular

A route guard, a security scanner and a green pipeline do not automatically make an Angular application secure. They can all be present while one customer downloads another customer's invoices. The uncomfortable part is that the code may look completely reasonable.

Security by design means making the security rules part of the feature before implementing it, enforcing them at the right boundaries and collecting evidence that they hold. For an Angular frontend with a .NET API, that starts with a question more useful than “which scanner should we install?”: what must this application never allow?

Start with something worth protecting

Imagine a customer portal. Customers sign in, view invoices and download documents. The important assets include the invoice contents, customer details and the ability to change business data. The browser, API and document storage are different parts of the system, with different responsibilities.

A minimal security requirement could be: a customer may read their own invoices and must not discover the contents of another customer's invoice, even when they know its identifier.

That sentence already improves the design. It tells us that knowing an ID is insufficient, that the rule must cover direct API calls, and that document downloads need the same attention as the invoice page.

Draw the boundaries

A small threat model can start with a sketch of data moving from browser to API to database and storage. Identify where identity is established, where permissions are evaluated and which inputs an outsider controls.

OWASP's threat modelling guidance offers a structured way to discuss threats and mitigations. For this example, I would begin with a few concrete questions rather than a long catalogue of attack names:

  • What if a customer changes the invoice ID in a request?
  • What if an employee loses access while their browser is still open?
  • What if a document link is forwarded to somebody else?
  • What if an export includes records the current user cannot view individually?
  • What if a failed operation is retried?

These questions should produce decisions and tests. If the outcome is only a diagram nobody revisits, the useful part has not happened yet.

The browser is not the boundary

An Angular guard can redirect someone away from a page. A hidden button can prevent confusion. Both are useful interface behaviour. Neither stops someone from sending a request directly to your API.

Angular's route guard documentation makes this distinction explicit: access control must also be enforced on the server. A user controls the client they run. Your backend must make its own decision.

Being logged in is not enough

Authentication establishes who the caller is. Authorisation decides what that caller may do with this particular resource. Adding [Authorize] to a .NET endpoint handles neither every ownership rule nor every business permission by itself.

For a deliberately simple application where each invoice belongs to one user, the query can be scoped to that authenticated user. This illustrative controller action assumes configured authentication, a trusted user identifier claim and an injected Entity Framework Core context named db:

[Authorize]
[HttpGet("api/invoices/{id:guid}")]
public async Task<IActionResult> GetInvoice(
    Guid id,
    CancellationToken cancellationToken)
{
    var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
    if (string.IsNullOrWhiteSpace(userId))
        return Forbid();

    var invoice = await db.Invoices
        .AsNoTracking()
        .Where(item => item.Id == id && item.OwnerId == userId)
        .Select(item => new {
            item.Id,
            item.Number,
            item.Total
        })
        .SingleOrDefaultAsync(cancellationToken);

    if (invoice is null)
        return NotFound();

    return Ok(invoice);
}

The important choice is that the owner comes from the authenticated identity, not from an ownerId supplied by the browser. The query never returns a different owner's invoice. The response also selects the fields this operation intends to expose.

This example chooses 404 for both a missing invoice and an invoice outside the user's scope. Another application might deliberately return 403. Decide what information the response may reveal and keep the policy consistent.

A shared business account usually needs more than a single owner comparison: current organisation membership, roles and operation-specific permissions. ASP.NET Core's resource-based authorisation supports evaluating a user against a resource and a policy. The exact rule belongs to your domain; copying a snippet cannot decide it for you.

Check every route to the data

Protecting the detail endpoint is not enough if a download endpoint returns any storage object by name. Lists, search, exports, updates and background jobs need appropriate scope too. A hard-to-guess identifier may reduce guessing; it does not establish permission.

OWASP's authorisation guidance recommends denying access by default and checking permissions on every request. I translate that into a practical review question: can I find another path to the same data that skips this rule?

Use Angular's protections deliberately

For ordinary user-provided text, use interpolation such as {{ customer.name }}. Angular escapes interpolated text. When rich HTML is genuinely required, understand the relevant sanitisation context and where the content originates.

Direct DOM manipulation and third-party widgets deserve extra scrutiny. Adding bypassSecurityTrustHtml to make a warning disappear changes the trust decision; it does not make the input safe. Angular's security guide explains these boundaries and recommends Content Security Policy and Trusted Types as additional protection.

I would introduce an appropriate CSP early enough to discover incompatible integrations. Test it in the deployed environment, then enforce it once the policy is correct. A policy left in report-only mode reports problems but does not block them. Nonces, where used, need proper per-response generation and integration with Angular.

Keep credentials out of the bundle

A frontend environment file is configuration, not a secret store. Values included in a delivered JavaScript bundle can be inspected. Privileged service credentials belong on a trusted backend with controlled access.

Choose the authentication design explicitly. If you use cookies, configure their security attributes and protect state-changing requests against CSRF. Angular's client-side XSRF support needs a compatible server implementation; a header the server never validates provides no protection. See the OWASP CSRF guidance for the different defences and their limits.

If the application uses bearer tokens, consider their lifetime, storage and exposure to script execution. Neither a cookie nor a token is a universal answer. Document the chosen flow, logout behaviour and what happens when access is revoked.

Try to disprove your design

I want a test setup with at least two ordinary users who do not share access, plus any privileged roles the feature needs. Testing only with the administrator account can conceal precisely the mistake we are trying to catch.

Give Alice one invoice and Bob another. Alice must be able to read her own invoice. She must not read Bob's invoice, change it, download its document or include it in an export. An unauthenticated caller must not receive either customer's data.

Test the API directly

A browser test showing that Bob's invoice is absent from Alice's list is useful, but insufficient. Request Bob's invoice directly using Alice's authenticated session.

The following Playwright example describes the contract I would add to an API integration suite. It uses project-specific fixtures: aliceApi is an authenticated request context for Alice, and seededInvoices creates isolated test records for both users. It is not a standalone test without that setup.

test('an invoice is only readable by its owner', async ({
  aliceApi,
  seededInvoices
}) => {
  const own = await aliceApi.get(
    `/api/invoices/${seededInvoices.alice.id}`
  );
  expect(own.status()).toBe(200);
  expect((await own.json()).id).toBe(seededInvoices.alice.id);

  const other = await aliceApi.get(
    `/api/invoices/${seededInvoices.bob.id}`
  );
  expect(other.status()).toBe(404);
  expect(await other.text()).not.toContain(
    seededInvoices.bob.number
  );
});

The successful request matters too. A broken endpoint that returns 404 for everything must not pass as a secure implementation. Test denied writes separately and verify that the stored record did not change.

Run this against the actual application boundary. Mocking the API response in an Angular test would only prove that the UI handles your mock. In backend integration tests, a test authentication handler can establish identities, but it must not replace the real authorisation logic you are trying to verify.

Review your assumptions

Before calling the feature done, I want a reviewer to trace one request from input to data access. Which identity is trusted? Can the caller change the scope? What is returned on failure? Does logging accidentally capture private document contents or credentials?

Try invalid input, missing permissions and stale sessions. Check that errors do not reveal stack traces or data from another customer. Where a feature moves money or changes important records, also review retries, concurrent requests and auditability. A scanner may find a dangerous API call; it cannot invent the missing business rule.

Give CI specific questions

“Security runs in CI” is too vague to review. Each check should answer a question, and the pipeline should make a failed or missing answer visible.

  • Build and type checks: does this revision compile with the intended configuration?
  • Authorisation integration tests: do the allowed and denied operations still behave as required?
  • Secret scanning: did the change introduce material that resembles a credential?
  • Dependency analysis: do resolved dependencies have known relevant vulnerabilities?
  • SAST: does source analysis find suspicious patterns or data flows?
  • Deployed checks: does the test environment enforce the expected headers, authentication flow and critical user journeys?

These checks provide different evidence. Passing one does not substitute for another. A dependency scan cannot prove that your invoice query is scoped correctly. A permission test cannot establish that every dependency is safe.

Decide what blocks a change

I would block on failed access-control tests and on new findings that meet an agreed, reviewed risk threshold. Findings need ownership, a triage process and a deadline where remediation is required. Temporary exceptions should record their reasoning and expire.

Run independent checks in parallel where practical and keep appropriate full analysis. If the pipeline is painfully slow, fix its feedback loop rather than quietly disabling the checks. I discuss that trade-off in The bullshit of slow SAST scans.

The pipeline itself is part of the design. Restrict credentials and permissions, protect deployment jobs and verify that required checks cannot be skipped by an ordinary merge. A workflow that analyses untrusted changes should not automatically hand those changes production access.

Keep the rules alive

Security by design does not finish at the first release. A new export, a new role or a new integration can change the assumptions. Revisit the relevant threats when those changes happen, and extend the tests along with the feature.

Keep a short record of the important decisions: what is protected, who may do what, where enforcement happens and how it is verified. That gives the next developer a starting point better than “we use Angular, so we should be fine”.

The practical result I want is straightforward. A developer can explain the security rule. A reviewer can locate its enforcement. A test can demonstrate that an unauthorised action fails. CI makes a regression difficult to overlook.

That is a much stronger foundation than a security task added to the end of the sprint. It is also the kind of engineering I bring to Angular and .NET projects: make the important boundaries explicit, then keep checking that the software respects them.

Comments

There are no comments yet, leave yours below.

Leave a comment

Do you have an addition, question or experience related to this article? Share it below.

Comments are briefly reviewed before they appear.

Read more about:

Starting fresh with Angular

A new Angular project is an opportunity to make fewer premature decisions. I work through a practical starting point: one useful vertical slice, clear feature boundaries, deliberate state management and tests that protect real behaviour. The goal is a codebase that can grow with the product, without building an imaginary enterprise platform on day one.

Continue reading

The bullshit of slow SAST scans

The bullshit of slow SAST scans

A security scan that keeps developers waiting can undermine the workflow it is supposed to protect. I look at feedback time, useful pipeline gates and the cost of noisy results. Faster checks should help teams act on real findings, without quietly removing the security coverage they need.

Continue reading

Unit testen van private methods in Angular

Hoe test je logica in een private Angular-method zonder die alleen voor een test public te maken? Dit artikel bespreekt testen via publiek gedrag, het verplaatsen van logica naar een service en een directe benadering wanneer dat niet past. De centrale vraag: wat wil je bewijzen, en hoeveel moet je test daarvoor van de implementatie weten?

Lees verder