Frontend security for enterprise apps: the client is untrusted
Frontend security in enterprise apps: on the server, in the UI, and the , , tokens, no secrets in the , audit.

A transfer button is disabled because the daily limit is reached. Anyone who opens can enable it again, or skip the button and call the API directly. If the limit only lives in the component, the bank has no limit.
That is the whole first lesson of frontend security in an enterprise app: the browser is the part you do not control. The frontend can make attacks harder. It cannot be where a rule is enforced.
The few concepts that matter
Access control runs on the server. puts it plainly: access control "is only effective when implemented in trusted server-side code or serverless APIs". Broken access control keeps the top spot in the OWASP Top 10:2025, and the listed weaknesses include bypassing checks "by modifying the URL" and tampering with a . The check in the component is UX. The check on the server is the rule.
RBAC in the UI is for clarity, not protection. Hide the approve button from a user who cannot approve, so the screen makes sense. Take the roles from the server's answer, and expect the server to refuse anyway. A frontend that decodes a token to decide what the user may do has moved the decision to the one place the user can edit.
for permissions, on the parent route. In , put one guard on the parent route of an area instead of one on every screen. canActivateChild "determines whether a user can access child routes of a particular parent route", so a new screen under /admin is covered without anyone remembering to add it. canMatch decides "whether a route can be matched during path matching", and when it says no, Angular tries the other routes: a user without the approver role can land on a read-only version of the same path. Both read the roles from the server's answer, and the Angular docs are clear on the limit: "Never rely on client-side guards as the sole source of access control."
XSS: keep the sanitizer on. Angular "treats all values as untrusted by default" and sanitizes values bound into the DOM. The bypassSecurityTrust* methods exist; the docs say "be careful", because trusting a malicious value introduces a vulnerability. React's equivalent is dangerouslySetInnerHTML, where untrusted HTML means you "risk introducing an XSS vulnerability". In review, treat both as a finding until someone proves the input is trusted.
CSP as the second layer. OWASP calls a strong Content Security Policy "an effective second layer of protection", and says it "should not be relied upon as the only defensive mechanism against XSS". Angular supports , and an autoCsp option for static hosting. To see what your site sends today, run it through Mozilla's HTTP Observatory. It "performs an in-depth assessment of a site's HTTP headers and other key security configurations".
Image: tt-tech.io, adapted from web.dev, Mitigate cross-site scripting (XSS) with a strict Content Security Policy (CSP), CC BY 4.0.
Tokens and sessions. OWASP: "Do not store session identifiers in local storage as the data is always accessible by JavaScript"; cookies "can mitigate this risk using the httpOnly flag". Cookies bring back. Angular's HttpClient reads an XSRF-TOKEN cookie and sends it as an X-XSRF-TOKEN header, and the docs are clear that this "must be mitigated primarily on the server side".
No secrets in the bundle, no direct third-party calls. Everything shipped to the browser is public. An API key in client code is a published API key. The backend holds the key and calls the outside service.
Audit without leaking. Regulated domains want to know who did what. The frontend helps with an on every request, and stays careful with what it logs: OWASP lists , , passwords, sensitive personal data and "bank account or payment card holder data" as data that should usually not be logged directly, only masked, hashed or removed.
The check in the component is for the user. The check on the server is for the bank.
When to choose what
- or token in memory: a cookie session through a when you control both sides; a short-lived token in memory when the API is shared with other clients. Not for session ids.
- CSP with nonces or hashes: nonces when a server renders each response;
autoCsphashes when the app is static files on a . - Hide or disable: hide actions a role never has; disable, with the reason shown, actions the user could have later (a limit, a missing signature).
On Atlas Bank
Atlas Bank's rules file says: "Every business rule is enforced at the boundary." The transfer rules live once, as a pure function in libs/domain/src/lib/transfer-rules.ts. The new transfer screen of module 5 (libs/feature/transfers/src/lib/new-transfer.ts on the track1/m5-security-solution branch) calls canTransfer() in a computed to show the reason. The boundary calls it again before any money moves, in libs/data-access/banking/src/lib/fake-backend.ts:
// The rule is enforced at the boundary. The client runs the same check for the UX; the server runs it for real.
const verdict = canTransfer({ from, toRib, amountMad, transferredTodayMad: this.transferredToday(fromAccountId) });
if (!verdict.ok) throw new Error(blockerMessage[verdict.reason]);The cheap layer is mechanical. eslint.config.mjs fails on bypassSecurityTrust* calls and on a fetch to an absolute URL. npm run scan:secrets fails on strings that match common key patterns in apps/ and libs/. Both run in npm run lint:arch. The repo's OWASP frontend map maps each risk family to what a frontend team controls, and is honest about the gaps: CSP, token handling and logging are not in the demo. There are no route guards yet either.
What I left out, and why
- , , . Real, mostly headers and configuration. One line each on a deploy checklist.
- and flows. The flow is chosen with the identity team; the frontend's part is where the token lives, covered above.
- Crypto in the browser. Not the frontend's job in a bank.
On Monday
- Search your codebase for
bypassSecurityTrustanddangerouslySetInnerHTML. Each hit gets an owner and a reason, or goes. - Pick one business rule the UI checks (a limit, a role) and find the line on the server that checks it too. If you cannot find it, that is a finding.
- Open your logging setup and look for tokens or personal data in what the client reports.
For your coding agent
The rules of this article, written for an agent. Paste them into your AGENTS.md, save them as a skill, or point your agent at the file.
# Frontend security rules for enterprise apps Source: https://tt-tech.io/blog/frontend-security-enterprise-untrusted-client For Angular and React frontends. Add to AGENTS.md or save as a skill. Your project's own rules win where they differ. ## Rules 1. The browser is untrusted. Every access rule and business limit is enforced on the server. A check in a component is only for the user's screen. 2. When you add a client-side check (a limit, a role, a permission), add or find the same check on the server in the same change. If there is none, report it as a finding. 3. Take roles and permissions from the server's answer. Never decide what the user may do by decoding a token in the client. 4. Route guards are for navigation, not protection. Put `canActivateChild` on the parent route of an area, use `canMatch` for role-based variants of a path, and still expect the server to refuse. 5. Hide actions a role never has. Disable, with the reason shown, actions the user could have later (a limit, a missing signature). 6. Keep the sanitizer on. Never call `bypassSecurityTrust*` (Angular) or use `dangerouslySetInnerHTML` (React) on data from a user or an API. If some markup is wanted, map an allowed subset to elements in the template. 7. Never store session ids or tokens in `localStorage` or `sessionStorage`. Use an `httpOnly` cookie session through a backend for frontend, or a short-lived token in memory when the API is shared with other clients. 8. With cookie sessions, keep CSRF protection on: in Angular, `HttpClient` sends the `XSRF-TOKEN` cookie back as an `X-XSRF-TOKEN` header, and the server must check it. 9. No secrets in client code: no API key, token or password in the frontend, not even temporarily. Everything shipped to the browser is public. 10. The browser never calls a third-party service directly. The backend holds the key and makes the call. 11. Treat the Content Security Policy as a second layer: nonces when a server renders each response, `autoCsp` hashes when the app is static files. Do not loosen it to make a script run. 12. Never log tokens, session ids, passwords, personal data or bank and card data from the client. Mask them or leave them out. Send an interaction id with each request instead, so server logs can be matched. ## Before you say done - Search the diff for `bypassSecurityTrust`, `dangerouslySetInnerHTML`, `localStorage` and strings that look like keys. Remove each hit or explain it in the pull request. - For every client-side rule in the diff, name the server code that enforces it. - List what you checked and what you could not check.
Sources
- OWASP, A01 Broken Access Control - OWASP Top 10:2025, 2025
- Angular, Control route access with guards
- Angular, Security
- React, Common components (e.g. <div>)
- OWASP, Content Security Policy Cheat Sheet
- MDN, HTTP Observatory
- OWASP, HTML5 Security Cheat Sheet
- OWASP, Logging Cheat Sheet
- web.dev, Mitigate cross-site scripting (XSS) with a strict Content Security Policy (CSP)