Back to Blog

Language: English

Returning Authorization Results as Booleans Loses What Was Authorized

Returning authorization results as booleans erases what was authorized from downstream types. This post combines spec kinds, conditional types, and branded types so the fact of authorization travels as a value all the way into service signatures.

Deciding authorization at runtime is a given; this article is about how to carry the result of that decision around afterward.

If you accept an authorization result as a boolean, everything after that point loses sight of what was authorized, against which target, under which action.

canAccess: boolean

All that survives is the fact that it was true. Whoever passes values into a service now has to remember, on their own, “this one should be already authorized.”

A State You Can’t Read from the Types Alone

Take a look at this service signature.

async updateUser(userId: UserId, dto: UpdateUserDto)

All that’s there is a plain UserId. From the type alone, you can’t tell whether this userId has been authorized, under which action it was authorized, or whether it’s even a value this service should accept. It’s a state that depends on the caller’s memory.

So the question becomes: can we carry the authorization result into the next layer as a type?

kind, Conditional Types, and Branded Types

The tools are three: a kind on the spec, conditional types, and branded types.

The spec’s kind

Give each authorization spec a kind.

  • self: looks at the relationship between the actor and the target
  • scope: defines the range of what may be returned
  • public-proof: verifies the proof through a public endpoint

Its role is to act as the discriminant that determines which authorization result comes back.

Deriving AccessResult

The same authorize(action) returns different results depending on the action.

type AccessResult<A extends AccessAction> =
  SpecOf<A> extends { kind: "self" }
    ? SelfAccessResult<A>
    : SpecOf<A> extends { kind: "scope" }
      ? ScopeAccessResult<A>
      : SpecOf<A> extends { kind: "public-proof" }
        ? PublicProofAccessResult<A>
        : never;

The mapping — user-meta:manage yields SelfAccessResult, tasks:list yields ScopeAccessResult — is expressed as a conditional type. Mismatches between action and result fail right here.

Authorized<Action, Value>

The moment you pull a UserId or Scope out of a result and fall back to a raw value, the fact of authorization disappears again. That’s where branded types come in.

Authorized<Action, Value>

This pins which action a value was authorized under onto the value itself.

Service Signatures

BeforeAfter
UserIdAuthorized<"user:update", UserId>

The parameter changes from a bare user id to “a user id authorized for user:update.” Code that accidentally passes an unauthorized UserId now fails with a type error at the service’s front door.

Where Brands Get Minted

You have to be careful about where you attach a brand to an object.

{ ...scope, actor: other }

A value whose contents were swapped out by a spread shouldn’t still carry proof of authorization. As a rule, mint brands only at the authorization boundary. Keep things shaped so copies can never leak the evidence.

AccessPolicy / Guard
  -> Authorized<Action, Value> を mint

Controller
  -> result を service に渡す

Service
  -> Authorized<Action, Value> を要求する

Calls We Want to Stop

The kinds of calls this design is meant to stop look like this:

  • passing an unauthorized ID
  • reusing an authorization result from a different action
  • passing a single subject where a scope is required
  • treating an unverified proof as verified

This isn’t an attempt to prove all correctness with types. Whether the policy’s logic is correct, or whether DB queries use scopes correctly — none of that is proven by this.

But the class of accident where an unauthorized value reaches a protected service now stops much earlier in the pipeline. Don’t let the fact of authorization die as a boolean — carry it as a value all the way into the service’s signature. That alone changes how strong your boundary is.


Based on LT slides I put together on the theme of expressing authorization with types.