Security Considerations in HTML

Markup is not where security is enforced, and several markup decisions can undermine it entirely. The ones every front end developer should know.

Concept

Security is enforced on the server. But a handful of markup decisions can open holes, and a handful of attributes can close them. This note covers what a person writing HTML needs to know.

1. Never trust anything from the client

Every attribute, every value, every validation rule in your markup can be changed in DevTools in seconds.

<!-- the reader can edit this to 1 before submitting -->
<input type="hidden" name="price" value="4999">

<!-- removed in two clicks -->
<input type="number" name="quantity" max="10">

<!-- deleted, and the field becomes editable -->
<input name="account_id" value="1043" readonly>

Send identifiers, not values. Let the server look up the price, the limit and the ownership from data it controls.

<!-- safe: the server resolves this to a price it owns -->
<input type="hidden" name="product_id" value="kettle-2l">

2. Cross site scripting

The most common web vulnerability. It happens whenever user supplied content is inserted into a page as markup rather than as text.

// dangerous: a script tag in the input becomes a script tag in the page
el.innerHTML = userComment;

// safe: inserted as text, always
el.textContent = userComment;
<?php
// dangerous
echo "<p>" . $_GET["name"] . "</p>";

// safe
echo "<p>" . htmlspecialchars($_GET["name"], ENT_QUOTES, "UTF-8") . "</p>";

The rule: escape on output, in the context you are outputting into. The escaping needed inside an attribute differs from the escaping needed in text content, which differs again inside a URL or a script.

<!-- an unquoted attribute lets an injected value break out -->
<div class=<?= $userClass ?>>

<!-- quoted, and escaped -->
<div class="<?= htmlspecialchars($userClass, ENT_QUOTES) ?>">

Where rich content genuinely must be allowed, sanitise it against a whitelist of permitted elements and attributes rather than trying to strip out dangerous ones. Blacklists are always incomplete.

3. Cross site request forgery

An attacker gets a logged in reader to submit a request they did not intend, from another site.

<form action="/account/delete" method="post">
  <input type="hidden" name="csrf_token" value="<?= $token ?>">
  <button type="submit">Delete my account</button>
</form>

The token is generated by the server, tied to the session, and verified on submission. Its safety comes from the server knowing what it issued - not from the field being hidden.

Two supporting rules:

  • State changing actions must be POST, never GET. A destructive action behind a link will eventually be triggered by a crawler, a prefetch or an email scanner.
  • Session cookies should carry SameSite=Lax or Strict.

4. target="_blank" and rel

<a href="https://example.org" target="_blank" rel="noopener noreferrer">External</a>

Without noopener, the opened page receives a reference to your window through window.opener and can navigate it elsewhere - a phishing technique known as tabnabbing. Modern browsers imply noopener for target="_blank", but stating it protects older ones and costs nothing.

5. iframes

<!-- untrusted content, tightly restricted -->
<iframe src="/preview" title="Document preview"
        sandbox="allow-scripts"></iframe>

sandbox blocks scripts, forms, popups and top level navigation, and you grant back only what is needed. Note that allow-scripts together with allow-same-origin on same origin content effectively removes the sandbox, because the framed page can rewrite its own attributes.

Protecting your own pages from being framed

Content-Security-Policy: frame-ancestors 'self'
X-Frame-Options: SAMEORIGIN

Clickjacking is loading your page invisibly over a hostile one and tricking a reader into clicking your buttons. Any page with a login, a payment step or a destructive action should send one of these headers.

6. Subresource integrity

<script src="https://cdn.example.com/lib.js"
        integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6..."
        crossorigin="anonymous"></script>

The browser verifies the downloaded file against the hash and refuses to run anything that does not match. It protects against a compromised CDN serving altered code - which has happened to real, widely used libraries.

7. Content Security Policy

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  frame-ancestors 'self';
  base-uri 'self';
  form-action 'self'

A policy that restricts where resources may be loaded from. It is the strongest single defence against cross site scripting: even if an attacker injects a script tag, the browser refuses to execute it unless the source is permitted.

It has a markup consequence. A strict policy blocks inline scripts and inline event handlers, so onclick attributes stop working. That is a good reason to have been using addEventListener all along.

8. Forms and credentials

<form action="/login" method="post">
  <label for="user">Username</label>
  <input type="text" id="user" name="user" autocomplete="username">

  <label for="pass">Password</label>
  <input type="password" id="pass" name="pass" autocomplete="current-password">

  <input type="hidden" name="csrf_token" value="...">
  <button type="submit">Sign in</button>
</form>
  • POST, never GET. A GET login puts the password in the URL, in history and in every server log.
  • HTTPS, always. Neither method encrypts anything by itself.
  • Correct autocomplete tokens, so password managers work properly - a security benefit, not only a convenience.
  • Do not disable paste on password fields. It discourages password managers and long passwords.

9. Never store credentials client side

// readable by any script on the page, including an injected one
localStorage.setItem("token", jwt);

Session credentials belong in a cookie marked HttpOnly, Secure and SameSite, which script cannot read at all.

10. Do not leak information

  • Comments in the markup are public. No internal URLs, no notes about known bugs, no credentials.
  • Data attributes are public and editable.
  • Error messages should not reveal file paths, stack traces or database details.
  • Do not list admin paths in robots.txt; it is a public file.

Important rules

  • Client side validation is a convenience, never a control.
  • Escape on output, in the correct context.
  • Every state changing form needs a CSRF token and POST.
  • Everything in the page source is visible and editable.
  • HTTPS is what encrypts, not POST.
  • A Content Security Policy is the strongest defence against injection.

Common mistakes

  • Trusting hidden fields, readonly attributes or max values on the server.
  • Using innerHTML with user supplied content.
  • Unquoted attributes holding interpolated values.
  • No CSRF token.
  • Login forms over GET or over plain HTTP.
  • Tokens in localStorage.
  • Third party scripts with no integrity hash.
  • Internal information in comments.

Best practices

  • Validate and authorise everything on the server, again.
  • Escape on output with the framework helper for that context.
  • Sanitise rich content against a whitelist.
  • CSRF tokens on every state changing form.
  • rel="noopener noreferrer" on external links opening in a new tab.
  • Send a Content Security Policy and frame-ancestors.
  • Use addEventListener, not inline handlers.
  • Keep credentials in HttpOnly cookies.

Practice

  1. Change a hidden field value in DevTools and submit. What must the server do to be safe?
  2. Insert a value containing markup with textContent and then with innerHTML.
  3. Add a Content Security Policy that blocks inline scripts and fix whatever it breaks.
  4. Audit a page for external links missing rel="noopener".

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All HTML notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.