ishan.np

Authorization as a type, not a checkbox

Most security tooling treats authorization as a boolean you remember to ask. Before a probe runs, somewhere in the call stack, there is an if authorized:. Miss that check on one path โ€” a retry handler, a new tool adapter, a "temporary" debug route โ€” and the safety story collapses into a hope that the operator noticed.

That model is fine for a script a human drives by hand. It is the wrong model for anything autonomous. An agent that can invent new call paths will invent paths that skip your checkbox. The interesting engineering question is not "did we remember to check?" It is "can an unauthorized action even be expressed?"

Authorization that lives only as a runtime boolean is a policy you hope to enforce. Authorization that lives in the type of the value is a constraint the program cannot forget.

The checkbox failure mode

Scatter enough assert scope.contains(host) calls through a codebase and you will feel safe. You will also be wrong in a predictable way. Checkboxes fail the same three ways every time:

  • Omission. A new egress path is added under deadline pressure. The check that lived three modules away is not copied. Nothing in the type system complains.
  • Staleness. Authorization is checked once at the start of a run, then a long-lived handle is reused after the token expires or the scope shrinks. The boolean was true then.
  • Bypass by composition. Two individually "safe" helpers are composed into a path that never re-validates the joint action. Each checkbox saw a local truth; the global action was never typed.

None of these are exotic. They are the ordinary failure modes of putting a cross-cutting invariant into unstructured control flow. If the invariant matters โ€” and for automated discovery it is the invariant that matters most โ€” it has to sit somewhere the compiler, the runtime, or the architecture will refuse to let you ignore.

Make the authorized thing a different type

The move is familiar from other domains that take invariants seriously. You do not pass a raw string where a validated email is required. You do not pass a raw integer where a sanitized ID is required. You wrap the value so that constructing it is the check, and every downstream function demands the wrapped type.

Applied to authorization for a discovery system, that looks roughly like this (sketched, not a library):

  • A bare host string, URL, or network handle is not something the egress layer will accept.
  • An AuthorizedTarget (or capability, or scoped handle) can only be constructed by a gate that has seen a valid authorization record for that exact subject, scope, and lifetime.
  • Every action that talks to the outside world takes an AuthorizedTarget โ€” not a string plus a hope. If you do not have one, you cannot call the function. The type checker is the first rail; the gate that mints the value is the second.

This is the difference between "check a flag before firing" and "you cannot fire without a capability that only the authorization module can mint." The discovery logic can still be clever about what to try next. It simply cannot name an unauthorized destination in a way the egress layer will honor.

Boundaries beat deep call stacks

Types alone are not enough if unauthorized values can still leak out through a side door โ€” a raw socket opened in a plugin, a subprocess that ignores your HTTP client, a DNS lookup that resolves past your allowlist. So the type discipline has to sit at the boundary between the discovery engine and the world.

In the research system behind my thesis, that boundary is deliberate. The engine proposes actions; an enforcement layer decides whether those actions are expressible against the current authorization and scope. The engine does not hold the keys to the network. It holds typed requests. Something else holds the ability to turn a typed, authorized request into packets.

If discovery logic and network egress share a process and an ambient authority, every "authorization check" is theatre. Separate the minting of authority from the use of it.

That separation is what makes the type useful. An AuthorizedTarget is not a sticker on a string. It is evidence that a gate โ€” a choke point you can audit, rate, and kill โ€” has already accepted this subject for this window. Steal the string and you still cannot mint the type. Skip the gate and there is nothing for egress to accept.

What this looks like in practice (generic)

Concrete patterns that keep showing up when you take this seriously, none of them tied to any particular target:

  • Newtypes / phantom types for scope. A host authorized for passive mapping is a different type from a host authorized for authenticated probing. You cannot accidentally widen the action class by passing the wrong handle into a deeper function.
  • Time-bounded capabilities. The authorized handle carries an expiry. Construction fails when the record is stale; use after expiry fails closed at the boundary, not with a warning log.
  • Program-token accounting at the gate. If authorization is a scarce resource (a per-program budget, a rate rail, a concurrent-session cap), spend it when the capability is minted โ€” not when some distant worker finally fires. That prevents "I already spent the token, so surely I can also take this side path" bypasses.
  • No ambient credentials in the discovery process. API keys, cookies, and client certs live behind the gate. The engine sees opaque handles, not secrets it could paste into an unauthorized request.

None of this requires a research language. It requires refusing to let str mean "whatever the agent felt like probing."

Why checkboxes keep winning in demos

Checkbox authorization is popular because it demos well. You can add a flag, ship a screenshot, and claim the tool "respects scope." Reviewers who only read the happy path will believe you. The cost shows up later: when a new adapter is bolted on, when a retry path reuses a stale handle, when two safe helpers compose into an unsafe joint action.

Typed, boundary-enforced authorization demos worse and holds up better. The first week feels pedantic โ€” every new egress call needs a capability, every test has to mint one through the gate, every plugin has to stop opening its own sockets. The payoff is that "did we forget to check?" stops being a category of bug. The remaining bugs are about the gate's policy being wrong, which is a much better class of problem to have: centralized, reviewable, and testable.

What I am measuring

For the thesis, this is not a style preference. It is part of the dependent variable. A rail that lives as scattered checks cannot be dialed; it can only be hoped. A rail that lives as a typed boundary can be instrumented: how often did the engine attempt an action the gate refused? How does coverage change when capability lifetimes shrink? When scope types are narrowed from "any HTTP" to "passive only"?

Those questions only have answers if unauthorized actions are unrepresentable at the egress boundary. Otherwise you are measuring documentation quality, not architecture.

Where this sits in the series

Earlier posts argued that safety has to be architectural, that the cost of those rails is measurable, and that you need a reproducible lab before the numbers mean anything. This post is the authorization rail in particular: not a README line, not a scattered boolean, but a type minted at a boundary the discovery engine does not control.

Next up in the build log: how scope itself gets encoded โ€” allowlists as data the gate owns, not as config the agent interprets charitably.


This is part of an open build log for my Master's research on safe automated vulnerability discovery. All experimentation runs against self-owned, authorized lab targets only. More about the research โ†’