[Deep Dive #11] Security Guardrails - discussion
Security guardrails in UI code often start as a pile of redirects and special cases until you have an incident and realize nothing is reproducible.
Do you model auth/permission posture as route truth (so redirects and access denials are reproducible), or keep it local to components? What evidence do you render so support can tell why a user got blocked or redirected without digging into logs? How do you avoid guardrails turning into distributed checks sprinkled across the tree?
Comments (10)
Back to latestI like the idea of rendering why you blocked, but doesn't that risk leaking sensitive info in the UI?
How do you keep evidence safe?
We keep evidence coarse: "blocked by auth" vs "blocked by role".
The evidence's job is to make behavior explainable, not to expose policy internals.
That makes sense. I was imagining evidence as basically a policy dump.
We treat auth posture as route truth because it affects navigation.
If you don't, you end up with effects that redirect after render and it's both janky and hard to reproduce.
This is basically the "make it observable" stance from Security and Safety and Routing and Navigation.
The only way we avoided distributed checks was centralizing guardrails into routines owned by the shell.
Screens don't decide. Screens ask.
Concrete guard routine pattern (keeps it testable and evidence-friendly):
ts
type GuardResult = { ok: true } | { ok: false; kind: 'auth' | 'role'; action: 'redirect' | 'deny' };
function requireRole(shell: any, role: string): GuardResult {
const user = shell.read('user');
const ok = Boolean(user) && user.roles?.includes(role);
if (ok) return { ok: true };
const res: GuardResult = { ok: false, kind: user ? 'role' : 'auth', action: user ? 'deny' : 'redirect' };
shell.write('guardEvidence', { kind: res.kind, action: res.action });
return res;
}
Now tests assert on guardEvidence and the UI can render a safe, coarse explanation.
Counterpoint: for simple apps, putting auth checks into every fetch layer might be enough.
Do we really need route truth for auth posture if all you're doing is showing a login screen?
If auth changes navigation, you benefit from making it route-visible. Even simple apps have "why did it redirect" bugs.
Also: fetch-layer-only tends to turn into a bunch of invisible retries and redirects. Evidence keeps it honest.
We hit a prod incident where anonymous users saw a cached logged-in page from an old tab.
Once we added an auth lane evidence key (and made cache posture depend on it), the class of bug became obvious and testable.
The best part of guardrails-as-contract is you can snapshot it. A screenshot can show guardEvidence=auth:redirect and support knows what happened.
Also: keep evidence stable and boring. If you change evidence shape every sprint, you lose the ability to use it in tests and triage.
Treat it like API.
We borrowed a lot of the "render-visible contract signal" language from Testing and Debugging.
We keep two evidence keys: auth=anon|user and guard=ok|redirect|deny. That's enough most of the time.
If a guardrail exists, it needs a place to live. Otherwise you end up with twelve "just in case" checks and no one knows which one mattered.