Security Trust

Authorization is where apps get personal

7 min read

Authentication answers the easy question: who showed up?

Authorization answers the question that actually makes the app personal: does this person get to touch this thing?

That second question is where trust lives. A user can be logged in and still be the wrong user for the object. They can be an admin in one workspace and a guest in another. They can own a note but lack access to the folder it was moved into. They can open a dashboard but lack permission for the customer records behind it. They can ask an AI feature a question that crosses into somebody else’s data.

Authentication makes the session real. Authorization makes the product respectful.

ownership is the first product rule

Most authorization bugs start with a missing ownership check.

The handler verifies that a session exists. It reads an id from the URL. It fetches the object. It returns the object. The code looks reasonable because every line is doing a normal thing.

The missing line is the one that proves the object belongs to the current user’s allowed world.

const note = await db.note.findFirst({
  where: {
    id: params.noteId,
    workspaceId: session.workspaceId,
  },
});

That workspaceId condition is not decoration. It is the product saying, “This note is only real for you inside this boundary.”

This is why I like authorization checks close to the data access path. Middleware can verify identity. Route guards can verify broad roles. But object ownership often depends on the query itself. If the query does not carry the boundary, the code is one refactor away from leaking data.

roles are too blunt by themselves

Roles are useful, but they are rarely enough.

admin, member, viewer, and owner sound crisp until the product grows. Admin of what? Which workspace? Which project? Which environment? Can the admin read billing data? Can they rotate keys? Can they export every record? Can they approve an agent action that writes to an external system?

A role without a resource boundary becomes a loaded word.

I prefer thinking in triples:

actor can action resource

Jeremy can edit post draft.

Support agent can view customer account summary.

Workspace admin can invite member to workspace.

Agent runner can read files under src/content/blog for this run.

That shape forces the resource into the sentence. It also makes weird permissions easier to spot. “Member can delete organization” sounds wrong immediately. “Admin can delete” sounds plausible until it is too late.

tenant boundaries should be boring

Multi-tenant authorization should be deeply boring.

Every query that touches tenant-owned data should carry the tenant boundary. Every background job should preserve it. Every search index entry should store it. Every cache key should include it when the cached value differs by tenant. Every analytics path should decide whether tenant data is allowed to mix before the data leaves the source system.

The dangerous bugs happen when one layer remembers the boundary and another layer forgets it.

The API handler checks workspace membership, then calls a service with only documentId. The service fetches by document id alone. The document id is globally unique, so tests pass until somebody guesses or observes a valid id. Or the app caches project-summary:${projectId} without including the workspace, then a migration changes id generation and the cache becomes a leak.

Authorization has to survive ordinary engineering work.

That means the boundary should be hard to omit. Types can help. Query helpers can help. Database constraints can help. Tests that create two tenants with similar-looking data help a lot because they catch the lazy path.

derived data inherits the boundary

Authorization gets trickier when the product creates derived data.

Search indexes, embeddings, summaries, recommendations, analytics tables, and AI memory stores all contain data that came from somewhere else. The derived object needs to keep the access rules of the source, or the product has built a side door.

This is especially easy to miss with AI features.

A document may be private. Its embedding lands in a vector index. A user without access asks a semantically similar question. The retrieval layer returns a chunk because the vector is close. The model paraphrases the private content. Nobody returned the original document, but the leak still happened.

The permission check belongs before retrieval results become context. Better yet, the index should carry metadata that makes unauthorized candidates easy to filter before ranking gets too far.

Derived data should answer:

  • which source objects created this?
  • which tenant owns it?
  • which users or groups can see it?
  • when did the source permissions last change?
  • does this copy need deletion when the source is deleted?

If the derived store cannot answer those questions, it is not ready for private data.

delegation needs names

Modern products have more actors than humans clicking buttons.

There are API keys, service accounts, scheduled jobs, automations, agents, browser extensions, integrations, and support tools. They act on behalf of people, teams, or systems. Authorization gets muddy when the product records only the executor and loses the initiator.

If an agent edits a file because I asked it to, the system should know both facts: I initiated the work, and the agent process executed it under a specific grant.

Delegation should be visible:

initiator: jeremy
executor: blog-agent
grant: edit src/content/blog for this run
action: update markdown file

That is not only an audit concern. It is an authorization concern. The executor should not inherit every permission the initiator has. It should get the permissions required for the delegated task, with a lifetime and a scope.

This matters for agents, but it also matters for ordinary integrations. A calendar integration should not become an all-purpose identity. A CI job should not receive a human admin token because it was easier to wire.

denial is part of the experience

Authorization failures should be designed.

The user should know whether they are logged out, in the wrong workspace, missing a role, trying to access a deleted object, or requesting something that exists but cannot be revealed. Those cases may need different copy because security and usability are in tension.

Sometimes the right message is specific:

You need workspace admin access to invite members.

Sometimes the right message is intentionally vague:

This document is unavailable.

The product has to choose. Leaving it to whatever exception bubbles out of the service layer is how authorization failures become confusing or leaky.

Good denial states also help support. A permission error with a clear reason, request id, and target type is easier to diagnose than a generic 403. The user may still need an admin, but at least the product is not pretending mystery is security.

test across people, not only paths

Authorization tests should create multiple users and multiple resources.

The most useful test is rarely “user can load their note.” It is “user cannot load another user’s note with a valid id.” Or “workspace admin cannot edit a project in a different workspace.” Or “deleted membership removes access to derived search results.” Or “agent grant expires at the end of the run.”

Happy-path authorization tests can create false confidence because authenticated access works. The bad path is where the product proves it respects boundaries.

I like tests with boring names:

member cannot read document from another workspace
viewer cannot rotate api key
expired agent grant cannot write file
removed user cannot retrieve old embedding result

Those tests describe product rules, not implementation details.

personal means bounded

People trust software when it respects what belongs to them.

That trust is easy to lose. One missing tenant filter, one overbroad role, one derived-data leak, one integration with too much authority, and the product stops feeling personal in the good way. It starts feeling porous.

Authorization is where the app proves that identity means something. The session says who arrived. The authorization layer decides what world they are allowed to inhabit.

That decision should be explicit, close to the data, visible in logs, and tested against the people who should be kept apart.

Jeremy London

About Jeremy London

Engineering leader and builder in Denver. I write about AI platforms, agents, security, reliability, homelab infrastructure, and the parts of engineering work that have to survive production.