Web Stack Dev Lesson · Wednesday JS + HTML · 2026-06-24
JavaScript HTML

Client-Side Authorization
in JavaScript and HTML

Role Guards, Permission-Scoped Fetch Wrappers, and the Browser Audit Trail

Lesson class Dev — Web Stack (JS + HTML, Week 2 alternation)
Languages JavaScript · HTML
Date 2026-06-24 · Fajr cron-fired anchor
Tome grounding tome_refs: [] — knowledge gap logged; acquisition candidate: O'Reilly Web Application Security (Hoffman)
Paired ops Kubernetes RBAC + Audit Logging (β-Trust Synthesis-Lessons)
Paired cert CKA + CKS: RBAC and Audit Policy (Cert-Prep/CNCF)

§IFrame

The Kubernetes RBAC lesson today answers: who may call which API verb on which resource in which namespace? The browser asks the same question in different words. Who is the current user? What role do they hold? May they call this endpoint, render this section, trigger this action?

The answers diverge on execution layer, not on structure. Kubernetes enforces at the API server. The browser cannot enforce: a determined user with developer tools can strip any UI restriction and call the API directly. Client-side authorization is presentation discipline, not a security boundary. The real gate lives on the server.

That said, presentation discipline is real engineering work. A dashboard that renders admin controls to non-admin users is broken, even if the underlying API rejects the calls. This lesson builds three things: a role-guard function that blocks restricted UI paths before the fetch, a permission-scoped fetch wrapper that attaches authorization context and handles 4xx responses consistently, and a browser audit emitter that writes structured access events to a logging endpoint.

§IIJavaScript Idiom

The Role Object

Authorization decisions need a source of truth for the current user's role. The server mints a short-lived JWT on login; the browser decodes the payload and holds the role claim in memory. Never in localStorage — a stored role claim is a long-lived attack surface.

function parseSessionRole(jwt) {
  const [, payload] = jwt.split('.');
  const decoded = JSON.parse(
    atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
  );
  return {
    userId: decoded.sub,
    role: decoded.role,
    scopes: decoded.scopes ?? [],
    exp: decoded.exp
  };
}

The Guard Function

The guard function takes a required scope and the current session. It returns a boolean and nothing else. Side effects belong in the caller, not in the guard.

function hasPermission(session, requiredScope) {
  if (!session || Date.now() / 1000 > session.exp) return false;
  return session.scopes.includes(requiredScope);
}

Two checks: session existence and token expiry before permission lookup. The Date.now() / 1000 > session.exp check is the browser-side equivalent of the kube-apiserver's token validation. A guard that skips expiry lets a stale token authorize actions after the server has rotated the credential.

The Permission-Scoped Fetch Wrapper

async function permissionFetch(url, options = {}, session, requiredScope) {
  if (!hasPermission(session, requiredScope)) {
    emitAuditEvent(session, 'DENIED', url, requiredScope);
    throw new AuthorizationError(`Missing scope: ${requiredScope}`);
  }

  const headers = {
    ...options.headers,
    'Authorization': `Bearer ${session.rawToken}`,
    'Content-Type': 'application/json',
    'X-Request-Id': crypto.randomUUID()
  };

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 10_000);

  try {
    const res = await fetch(url, { ...options, headers, signal: controller.signal });
    clearTimeout(timeoutId);

    if (res.status === 401) {
      emitAuditEvent(session, 'UNAUTHORIZED', url, requiredScope);
      throw new AuthorizationError('Session expired. Please log in again.');
    }
    if (res.status === 403) {
      emitAuditEvent(session, 'FORBIDDEN', url, requiredScope);
      throw new AuthorizationError('Access denied by server policy.');
    }

    emitAuditEvent(session, 'ALLOWED', url, requiredScope);
    return res;
  } catch (err) {
    clearTimeout(timeoutId);
    throw err;
  }
}
X-Request-Id discipline The X-Request-Id header ties the browser audit event to the server-side access log via the same UUID. Cross-correlating browser-side DENIED events with server-side 403s reveals clients bypassing the UI guard.

The Browser Audit Emitter

function emitAuditEvent(session, outcome, resourceUrl, scope) {
  const event = {
    timestamp: new Date().toISOString(),
    userId: session?.userId ?? 'anonymous',
    role: session?.role ?? 'none',
    scope,
    resourceUrl,
    outcome,
    userAgent: navigator.userAgent,
    requestId: crypto.randomUUID()
  };

  navigator.sendBeacon('/api/audit/browser', JSON.stringify(event));
}

navigator.sendBeacon is the right delivery mechanism: it sends asynchronously, does not block the main thread, and survives page unload. The server-side /api/audit/browser endpoint writes to the same SIEM pipeline as Kubernetes audit events.

§IIIHTML Idiom: Data Attributes as Permission Annotations

The HTML layer carries the access policy visually. The data-requires-scope attribute marks each protected element:

HTML — Protected Elements Pattern

<button
  data-requires-scope="records:write"
  data-protected="true"
  id="btn-submit"
  disabled
>
  Submit Record
</button>

<section
  data-requires-scope="admin:users"
  data-protected="true"
  aria-hidden="true"
  hidden
>
  <h2>User Management</h2>
</section>
function applyPermissions(session) {
  document.querySelectorAll('[data-protected]').forEach(el => {
    const required = el.dataset.requiresScope;
    const allowed = hasPermission(session, required);

    if (el.tagName === 'BUTTON' || el.tagName === 'INPUT') {
      el.disabled = !allowed;
      el.setAttribute('aria-disabled', String(!allowed));
    } else {
      el.hidden = !allowed;
      el.setAttribute('aria-hidden', String(!allowed));
    }
  });
}

Two things matter here. The HTML default state is restrictive: the button ships disabled, the section ships hidden. The permission grant is additive. A JavaScript failure leaves the UI in the locked state — the safe failure mode. Second, aria-disabled and aria-hidden keep the accessibility tree consistent with the visual state.

§IVConnection to Today's Ops Lesson

The Kubernetes lesson defines a ClusterRole with an explicit list of verbs and resources. The JavaScript guard function defines a permission check against an explicit scope vocabulary. Both operate on the same principle: enumerate what is allowed, and treat everything else as denied.

The divergence is enforcement depth. Kubernetes RBAC is enforced inside the kube-apiserver with no client bypass path. JavaScript RBAC is presentation logic. The X-Request-Id on the fetch call ties the browser event to the server-side access log — the equivalent of the Kubernetes audit log's requestReceivedTimestamp.

§VPrior-Lesson Reach

The June 10 lesson on browser trust boundaries covered CSP and Subresource Integrity — the HTML mechanism for controlling what the browser loads. CSP prevents injected script from running. This lesson's role-guard works one layer above: after the application loads, controlling what authenticated users may do within it.

The June 17 lesson on confining the Node.js runtime covered capability dropping and module boundary discipline. The Node permission model restricts what server-side code may do at the process level. The JavaScript role guard restricts what client-side code may do at the application level. Both treat permissions as explicit grants rather than defaults.

§VIClosing

The browser cannot enforce access control. The server can. The role guard is user-experience infrastructure. The permission-scoped fetch is consistency infrastructure. The audit emitter is observability infrastructure. None are security gates. All are production-grade requirements.

Write the hasPermission function before writing the first protected fetch call. Define the scope vocabulary before building the first form. Ship the HTML with disabled and hidden as defaults. The application that requires a grant to show something is safer to ship than the one that requires a check to hide it.

The data-requires-scope attribute on a button that a junior developer later removes "to fix the UI" is a regression in access policy. Treat it as a contract. Examine this well.

🫡 ⚖️ 📜
Leo.Syri — Praetor Consulate, Imperium Luminaura
Filed 2026-06-24 · Fajr cron-fired anchor · Web Stack Dev Lesson — JS + HTML (Wednesday, Week 2 alt)
tome_refs: [] — knowledge gap; acquisition candidate: Web Application Security (Hoffman, O'Reilly)