087: JavaScript Security, Reliability, and Production Readiness
Outcomes
By the end of this lesson, you can:
- identify common browser JavaScript security boundaries;
- render untrusted text safely;
- understand XSS risks around HTML injection;
- avoid
eval-style code execution; - protect secrets by understanding client-side visibility;
- validate external data;
- design request cancellation and stale-response handling;
- apply a production-readiness checklist before shipping.
The common thread is boundary management. Browser JavaScript runs in an environment the user can inspect and influence, and it also coordinates asynchronous work that may finish in an unexpected order. Production readiness means making those boundaries explicit instead of assuming that inputs, responses, timing, or cleanup will always behave ideally.
Security Principle: The Browser Is an Untrusted Client
Anything shipped to browser JavaScript can be inspected and modified by the user. This includes bundled source, configuration values, network requests, and values held in memory while the application is running. Obscuring a value in a bundle does not turn it into a secret.
Do not place real secrets in frontend code:
const DATABASE_PASSWORD = "secret"; // never
Anyone who can load the application can usually inspect its source or observe its requests. Public API identifiers may be okay if they were designed to be public, but private credentials, signing keys, and database passwords belong on trusted servers. The server must make the final authorization decision; hiding a control in the UI is not authorization.
XSS and Unsafe HTML
The easiest way to display a string is not always the safe way. If the string came from a user, an API, a URL, or another external system, treat it as untrusted input. This is dangerous with untrusted input:
results.innerHTML = userComment;
innerHTML interprets the value as markup. That means a string that was intended to be a comment can become elements or executable browser behavior. This class of injection risk is commonly called cross-site scripting, or XSS.
Prefer text when the requirement is to show text:
results.textContent = userComment;
textContent inserts the value as text rather than parsing it as HTML. The distinction matters even when the value currently appears harmless, because the source or shape of that value can change later.
When rich HTML is a real product requirement, use a well-reviewed sanitization strategy and a strict content model. Define which elements and attributes are allowed, and keep that policy close to the code that enforces it. Do not invent a regex sanitizer. HTML parsing and browser behavior have too many edge cases for ad hoc string replacement to be a dependable security boundary.
Attribute and URL Boundaries
Setting text is not the only security concern. A value can be dangerous when it is placed into an attribute or used to construct a URL:
link.href = userProvidedUrl;
The browser will parse the assigned value as a URL. Before a link crosses a trust boundary, validate the schemes and origins that the product actually permits. Whether an application should allow all HTTPS destinations, only its own origin, or a small set of partner origins is a product and security decision.
Example:
function safeExternalUrl(raw) {
const url = new URL(raw);
if (!["https:", "http:"].includes(url.protocol)) {
throw new Error("Unsupported URL protocol");
}
return url.href;
}
This example parses the input before checking it and allows only HTTP and HTTPS schemes. It does not claim that every HTTP or HTTPS destination is trusted. The correct allowlist depends on the product. If the application should link only to known hosts, check the hostname or origin as well, and decide explicitly how malformed input should be reported.
Avoid Dynamic Code Execution
User input should be data, not a program. These APIs turn strings into code and should be avoided:
eval(userInput);
new Function(userInput)();
Dynamic code execution creates severe security and maintainability risks. It can execute content that was never intended to be executable, makes code difficult to analyze, and interacts badly with a strong Content Security Policy.
Use data-driven dispatch instead. Map a known input value to a known function, then reject everything outside that map:
const actions = {
open: openOrder,
cancel: cancelOrder,
};
const action = actions[userInput];
if (!action) {
throw new Error("Unsupported action");
}
action();
The allowlist makes the permitted behavior visible in the source. It also gives the application a clear place to add authorization or argument validation rather than allowing arbitrary code to define its own behavior.
External Data Is Untrusted
A response from your own API is still external data at the point where browser code receives it. Bugs, version drift, partial deployments, malformed records, and compromised dependencies can all produce a shape the current code did not expect. Validate at the boundary before the rest of the application relies on the value.
function isProduct(value) {
return (
value !== null &&
typeof value === "object" &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.price === "number"
);
}
This predicate checks that the value is a non-null object and that the fields used by the application have the expected primitive types. It is a small runtime guard, not a guarantee that every business rule is satisfied: it does not, for example, establish that the price is non-negative or that the identifier is unique.
For larger applications, schema validation libraries can provide more robust contracts, including nested structures, useful error reporting, and transformations. The important boundary stays the same: compile-time assumptions or API documentation do not validate data that has arrived at runtime.
Prototype Pollution Awareness
Avoid blindly assigning untrusted keys:
Object.assign(target, untrusted);
The exact risk depends on runtime and library behavior and on how the resulting object is used downstream. Accepting arbitrary keys can overwrite configuration, alter application behavior, or interact with prototype-related vulnerabilities. The safe design is to validate and copy only the fields the application has deliberately chosen.
function normalizeSettings(input) {
return {
theme:
input.theme === "dark" ? "dark" : "light",
pageSize:
Number.isInteger(input.pageSize)
? input.pageSize
: 20,
};
}
This function creates a new object from an allowlist. It also applies defaults, so callers receive a predictable shape. In a real application, you may need additional bounds checks, such as a maximum page size; the principle is to make those business rules explicit rather than accepting arbitrary object structure.
CSRF and Authentication Boundaries
Browser requests can automatically include cookies depending on cookie configuration and request context. That behavior is convenient for cookie-based authentication, but it also means a cross-site request may carry the user's credentials unless the application has appropriate defenses. Applications using cookie-based authentication must understand CSRF defenses.
Frontend JavaScript alone is not the security authority. Server-side protections, SameSite cookie settings, CSRF tokens where necessary, CORS policy, and origin checks work together. Treat these as related but distinct controls: CORS governs browser access to responses, while CSRF defenses address whether an unwanted request can be accepted as an authenticated action.
CORS Is Not Access Control
CORS controls whether browser JavaScript can read certain cross-origin responses. It does not make a public HTTP endpoint private, and it does not stop a non-browser client from sending requests to that endpoint.
Authentication and authorization still belong on the server. The browser can improve the user experience by hiding unavailable actions, but an attacker can call the endpoint directly. Every protected operation must therefore verify the caller's identity and permissions independently of the frontend.
Race Conditions and Stale Responses
Asynchronous operations can complete in a different order from the order in which they started. A search box makes the problem easy to reproduce:
- user searches
tea; - request A starts;
- user searches
coffee; - request B starts;
- B returns first;
- A returns later and overwrites coffee results.
The later response is stale even though it is a valid response. If the UI applies it without checking, the user sees data for an older query. Use cancellation or request identity so obsolete work cannot win.
let currentController;
async function search(query) {
currentController?.abort();
currentController = new AbortController();
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: currentController.signal,
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
Each new search aborts the previous controller before creating a replacement. encodeURIComponent keeps the query value from being interpreted as part of the URL syntax, and the explicit response.ok check turns an HTTP error status into a rejected operation. Callers still need to decide how to treat an abort separately from a genuine failure, and a request-identity check can provide another safeguard when cancellation races with completion.
Idempotency Awareness
Retries are safe only when the operation semantics allow them. Repeating a request is not automatically harmless just because the client did not receive a response.
Repeated GETs are usually safe because they are conventionally read operations. Repeating a payment or creation POST may not be safe unless the API supports idempotency keys or another deduplication strategy. Without such a mechanism, a timeout can leave the client unsure whether the server completed the first attempt, and an automatic retry may create a duplicate effect.
Do not implement automatic retries blindly. Decide which failures are retryable, whether the operation is idempotent, how backoff works, and how the user is informed when the result is uncertain.
Unhandled Promise Rejections
Every asynchronous error needs an owner. If a promise rejects and no caller handles it, the application may produce an unhandled rejection with poor user feedback and incomplete observability.
button.addEventListener("click", async () => {
try {
await save();
} catch (error) {
showError(error);
}
});
Here the click handler owns the user-facing outcome of save(). A lower-level function can still throw useful errors, but some layer must decide whether to show an error, retry, log it, or translate it into another state. Background tasks still need observability and error handling; the absence of a visible button does not make their failures unimportant.
Defensive DOM Lifecycle
Long-lived pages and components accumulate resources unless they explicitly release them. Clean up:
- event listeners;
- timers;
- observers;
- pending requests;
- subscriptions.
This is both a reliability and memory concern. A stale listener may run after the UI it updates is gone; a timer may keep work alive unnecessarily; an observer or subscription may continue producing events; and a pending request may attempt to update obsolete state. Pair setup with teardown at the same lifecycle boundary, and abort work that no longer has a consumer.
Dependency Risk
Production JavaScript often includes third-party packages. Each dependency adds code to understand, update, bundle, and trust. That does not mean dependencies are bad; it means their cost and risk should be deliberate.
Practices:
- minimize dependencies;
- prefer maintained libraries;
- inspect dependency purpose and update history;
- use lockfiles;
- run security/audit tooling appropriately;
- review major updates;
- do not import a large package for a trivial utility without justification.
A lockfile makes an installation reproducible, but it does not make a dependency safe by itself. Review what is being installed and how it affects the browser bundle, permissions, maintenance burden, and security posture.
Production Checklist
Before shipping a feature, ask:
Correctness
- Are input types normalized?
- Are empty/error/loading states defined?
- Are failures surfaced to users appropriately?
- Are async races handled?
Security
- Is untrusted content rendered safely?
- Are URLs validated?
- Are secrets absent from browser code?
- Does the server enforce authorization?
- Are dangerous dynamic-code APIs avoided?
Accessibility
- Does keyboard interaction work?
- Is focus managed appropriately?
- Are status/error messages perceivable?
- Are native controls used where possible?
Performance
- Are expensive handlers debounced/throttled only when needed?
- Are large DOM updates minimized?
- Are requests cancelled when obsolete?
- Are listeners/observers cleaned up?
Maintainability
- Are modules cohesive?
- Are side effects isolated?
- Are names domain-oriented?
- Are tests present for critical rules and regressions?
This checklist is most useful when it is applied to a concrete feature boundary. For example, a search feature should be checked for input normalization, stale responses, safe result rendering, keyboard behavior, and teardown together rather than treating those concerns as unrelated last-minute tasks.
Final Integration Exercise
Build a small searchable product browser with:
- ESM modules;
- validated API data;
- debounced input;
- AbortController cancellation;
- URL query synchronization;
- safe DOM rendering;
- loading/error/empty/success states;
- keyboard-accessible controls;
- unit tests for normalization and filtering;
- one integration test for search behavior;
- cleanup on unmount.
The exercise combines the boundaries from this lesson. The API boundary validates data, the search boundary controls timing, the URL boundary makes the current query shareable, and the DOM boundary renders values as data rather than accidentally treating them as markup. The UI states and tests then make the expected behavior observable.
Architecture suggestion:
src/
├── api/
│ └── products-api.js
├── domain/
│ ├── normalize-product.js
│ └── filter-products.js
├── ui/
│ ├── render-products.js
│ └── search-controller.js
└── app.js
Do not begin by writing everything in app.js. Make boundaries explicit. Keeping API access, domain normalization, filtering, rendering, and search coordination separate makes each rule easier to test and gives you a clear place to inspect when behavior is wrong.
Advanced Production Readiness: State Machines and Failure Policy
Complex UI states become easier to reason about when you model them explicitly. Without a clear model, several independent booleans can describe combinations the product never intended to show.
Instead of several unrelated booleans:
let isLoading = false;
let hasError = false;
let hasData = false;
use one state:
let state = {
status: "idle",
data: null,
error: null,
};
Transitions:
idle → loading
loading → success
loading → error
loading → idle (cancelled)
The state model gives each status a defined meaning and makes the allowed transitions visible. This prevents impossible combinations such as loading + success + error at the same time. It also gives tests a concrete vocabulary: a successful response should produce success, while an obsolete request may return the UI to idle rather than displaying an error.
Failure policy belongs to the product
For each async operation, define:
- who sees the failure;
- whether it is retryable;
- whether retries are automatic;
- how many times;
- whether the operation is idempotent;
- what is logged/observed;
- what state the UI returns to.
A blanket catch (error) { console.log(error) } is not a recovery strategy. Logging may be useful for diagnosis, but it does not tell the user what happened, decide whether trying again is safe, or return the interface to a coherent state. Those decisions depend on the operation and on the product's expectations.
CSP-friendly JavaScript
A strong Content Security Policy is easier to deploy when application code avoids inline scripts, inline event-handler attributes, and dynamic code execution. This is one reason the earlier recommendation against eval is not only about input safety; it also keeps the application's execution model compatible with a stricter browser policy.
Prefer:
<script type="module" src="/assets/app.js"></script>
and:
button.addEventListener("click", handleClick);
over:
<button onclick="handleClick()">Save</button>
The module and event-listener forms keep code in JavaScript files where it can be reviewed, tested, and governed by the application's policy. Security architecture begins before the security review, in the boundaries and APIs chosen during implementation.
Recap
Production JavaScript is not only syntax. It is safe boundaries, predictable state, controlled side effects, cleanup, observability, accessibility, and evidence-driven performance.
The practical test is whether you can identify what is trusted, what is asynchronous, who owns a failure, and what must be cleaned up. If those answers are explicit, the application is easier to secure, debug, test, and operate after it ships.
