Skip to main content
Identity & Access

Authentication and permissions, done once

Most products build this three times: once at the start, once when the first serious buyer asks for single sign-on, and once when permissions stop being three roles. The third rebuild is the expensive one, and it is the one you can design away.

Three rebuilds, and only the last one hurts

The first version is email and password with an is_admin column, and it is the correct thing to build. The second arrives when a buyer's security review asks for single sign-on, and it is annoying but bounded, because authentication lives behind one door. The third is the one that costs a quarter. It arrives when someone asks whether a manager can see their reports' records but not edit them, whether a customer can share one project with an outside auditor, and whether a support engineer can act on behalf of a user without becoming an administrator. By then there are four hundred permission checks scattered through the codebase, no two written the same way, and nobody can answer "who can see this record" without reading code.

The distinction that prevents the third rebuild is old and still gets collapsed: authentication answers who you are, authorization answers what you may do. They have opposite economics. Authentication is a solved, commoditized problem where building your own is nearly always a mistake. Authorization is specific to your domain, cannot be bought outright, and is where the design effort belongs. Teams routinely reverse this, writing their own password reset flow and then expressing a rich permission model as a string column with three values.

What follows is what we would do at the start of a product, and what we do when we are handed a codebase where the third rebuild has already become unavoidable.

You are probably here because

  • A buyer's security questionnaire asked for single sign-on and directory provisioning and you have neither
  • Nobody can answer “who can see this record” without opening the code
  • A customer wants to share one project with an outside collaborator and your model has no way to say that
  • Someone left the company three weeks ago and still had access to something

The first is a two-week integration. The middle two are the model problem this article is about. The last one is a provisioning problem, and it is the one that gets a company into real trouble.

Buy authentication. All of it.

Password storage, reset flows, second factors, device sessions, account recovery, brute-force protection, enumeration resistance, breach notification: this is a large surface with well-understood failure modes and no competitive value. Use an identity provider or an established library, and use standard protocols. For business software that means an authorization-code flow with the current recommended proof-key protections, tokens verified against published keys, and no shortcuts for the mobile client.

Two things are worth insisting on when you choose. First, that you can support several identity sources at once. A real product ends up with employees on your own directory, customers on theirs, and a handful of accounts that predate all of it. Second, that user records in your database are keyed by a stable internal identifier rather than by email address. Email addresses change, get reassigned after someone leaves, and differ in case and in plus-addressing. A permission model keyed on email is a data-integrity incident waiting for a marriage or an acquisition.

The one authentication decision genuinely worth your own thought is session lifetime, because it is a tradeoff between how often people are interrupted and how quickly a revoked access actually stops working. Say the numbers out loud rather than accepting a default.

Session mechanismFitsRevocation behaviorCost
Server-side session, cookie referenceWeb applications with one backendImmediate. Delete the record and it is overA session store on the request path
Short signed token plus refreshAPIs and clients across several servicesDelayed by token lifetime, typically 5–15 minutesRefresh flow, rotation, and reuse detection
Long-lived signed tokenConvenient, and hard to undoEffectively none until it expiresLooks free, then a deny list appears anyway
Opaque token with introspectionSensitive actions, machine clientsImmediateA network call per validation, or a short cache

Most products land on short signed tokens with rotating refresh tokens, and that is reasonable if you accept the consequence in writing: when access is revoked, it takes up to the token lifetime to bite. If some action must revoke instantly — suspending an account, removing a departing employee — that path needs a live check rather than a cached claim. Deciding which actions those are is a five-minute conversation that saves an ugly one later.

Where the effort belongs — our default weights

The authorization model and its vocabulary
28
One decision point, enforced everywhere
22
Filtered list queries, not per-record checks
17
Provisioning and offboarding
14
Machine identity and key rotation
11
Authentication integration itself
8

Weights sum to 100. Our starting allocation, not a measurement. Note the last row: login is the smallest part of the work.

Three authorization models, and how to tell which one you need

Almost every access question in application software is one of three shapes, and choosing the wrong shape is what forces the rebuild.

Roles. Permission follows a named role: admin, editor, viewer. Simple, easy to explain, and sufficient for a surprising number of products. It fails the first time the answer depends on the specific record rather than the person, which is the moment somebody says "editors should be able to edit their own team's projects, not everyone's."

Attributes. Permission is computed from properties of the user, the resource and the context: department, classification of the record, time of day, network. Expressive, and it fits rules that come from policy rather than from structure. The cost is that the rules are code-like, and asking "who can see this record" means evaluating the policy against every candidate user rather than looking something up.

Relationships. Permission follows a graph: this user is an owner of this folder, this folder is a parent of this document, therefore this user may read the document. This is the model behind most collaborative software, and it is the one that handles sharing, nesting and delegation naturally. It answers both directions well — what may this user see, and who can see this thing — which is precisely what the other two struggle with.

The practical advice: start with roles if your product genuinely has global roles, but write every check through an interface that could later be backed by something else. Adopt relationships when sharing, hierarchy or per-object collaboration appear in the requirements, because retrofitting a graph after four hundred inline checks is the expensive rebuild in question. Reach for attributes for the small set of contextual rules that genuinely are rules, and keep them few.

The question that decides your model is not "what roles exist." It is "can two users of the same role see different records." If yes, roles are already insufficient and every month you wait adds checks to untangle.

Centralize the decision, distribute the enforcement

The single most valuable structural property is that there is exactly one place in the codebase that decides whether an action is allowed, and it is called from everywhere. One function, one service, one library — the shape matters less than the singularity. Everything good follows from it: you can log every decision, test the model in isolation, change models without touching feature code, and answer questions about the system without reading it.

Enforcement, by contrast, has to happen at every entry point: HTTP handlers, background jobs, scheduled tasks, message consumers, administrative scripts, the reporting export. Background jobs are where we most often find gaps. A job runs as "the system," inherits everything, and quietly becomes a way for a request to reach data the requesting user could not have reached directly. Give jobs an explicit acting identity, and if the correct answer really is full access, make that an explicit privileged identity that is logged rather than an accident of context.

Do not enforce in the interface alone. Hiding a button is a courtesy to the user; it is not access control, and every so often somebody discovers that the underlying endpoint never checked. If your interface hides an action, the server must refuse it independently.

Where these models break

The list endpoint, not the detail endpoint

Checking whether a user may open record 4821 is easy. Returning page one of the records they may see, sorted and paginated, is the problem that decides your architecture. Fetching everything and filtering in application code produces a query that gets slower every month and a page count that is wrong. The workable shapes are two: push the constraint into the query as a join or predicate the database can index, or maintain a materialized set of accessible identifiers per user, refreshed on change. Both are real work. Neither is possible if permission logic lives in scattered if statements, which is the actual reason a centralized model matters.

Describe your hardest permission rule and we will tell you which model it needs.

Send the two or three access rules you find hardest to express today, plus roughly how many permission checks are in the codebase, to contact@precisionfederal.com. You get back a short written note on which model fits, what the migration looks like, and what we would not bother changing. One business day. No charge, no meeting, no deck.

contact@precisionfederal.com

Machine identity, which is where the real keys leak

Service accounts and API keys usually get less attention than user login and are more often the cause of a bad afternoon. Four habits carry most of the value.

Give every service its own identity. A shared credential across five services means a rotation requires five coordinated deploys, so it never happens, so the key from 2023 is still live.

Scope keys narrowly and say so in the key. A key that can read one dataset is a smaller problem when it appears in a public repository than a key that can do anything. Prefix keys with an identifiable string so automated secret scanners can recognize them, and store only a hash of the key on your side so a database read does not hand over working credentials.

Make rotation routine before it is urgent. Support two live keys per identity so rotation is issue, deploy, revoke, with no downtime. If rotation requires a maintenance window, it will only ever happen during an incident, which is the worst time to be exercising an untested procedure.

Record last-used timestamps. The cheapest security work available is a monthly list of credentials nobody has used in ninety days. Most of them are safe to delete, and the ones that are not tell you something about your system you did not know.

Provisioning, and the offboarding gap

Access granted by hand is access removed by memory. The gap between someone leaving and their access ending is a number your company has, whether or not anyone has measured it, and in organizations without automated provisioning it is commonly measured in weeks.

Drive membership from groups in your directory rather than from per-user grants, so that a change in one place propagates. Support automated provisioning and deprovisioning from the identity provider for buyers who ask for it; for a business product this is a recurring requirement and it is easier to build once than to negotiate per deal. Then close the gaps automation misses: shared accounts, keys sitting on a laptop, the read-only database credential someone was given during an incident and everyone forgot.

Run an access review on a schedule, and make it cheap enough that it actually happens. A quarterly list of who has elevated access, sent to the person accountable for approving it, with a one-click removal, catches more than an annual exercise nobody has time for.

Audit, break-glass and impersonation

Log the decision, not just the request. An audit trail that records "user 12 called DELETE /records/91" is half a record; what you want later is "user 12 was permitted to delete record 91 because they hold the owner relationship on project 7." Decision logs turn an argument into a lookup, and they are how you debug a permission model that is telling users no.

Support engineers need to see what a customer sees, which means impersonation, which means the two rules that make it safe: every impersonated action is recorded as the operator acting as the user, and the mode is visually unmistakable to whoever is in it. Never let impersonation grant permissions the target user does not have — the point is to reproduce their view, not to become an administrator with a different name on the log line.

Break-glass access is the same discipline: a real path to elevated access when something is broken, with a reason recorded and a notification sent to someone else. The alternative is not that emergencies do not happen; it is that they are handled with a shared credential nobody logs.

Cost to change after the product ships

Moving from roles to a relationship model
94
Centralizing checks that are scattered inline
86
Rekeying users off email onto a stable identifier
74
Making list queries permission-aware in the database
66
Adding single sign-on and directory provisioning
34
Adding decision logging and access review
20

Difficulty as we rank it, driven by how much application code each change touches. Single sign-on is not the hard part, which surprises people.

Test the denials, because nobody does

Permission test suites are almost always positive: the admin can do the admin thing. The failures that matter are the other direction, and they are rarely tested at all. Write the negative cases explicitly. A user of one customer cannot read another customer's record by guessing an identifier. A viewer cannot reach the write endpoint directly. A revoked user's existing token stops working within the stated window. A background job triggered by a low-privilege user does not perform a high-privilege action. A list endpoint returns no rows the caller may not see, and its total count matches what it returned.

Two practices make this affordable. Write a fixture of users spanning every role and relationship shape, and run the whole endpoint surface against all of them, asserting the expected allow or deny for each pair. And add a test that fails when a new endpoint is registered without an authorization decision attached, so the enforcement gap is caught by continuous integration rather than by a customer. That single test is worth more than most of the suite, because the recurring bug is not a wrong rule, it is a missing check.

What "done once" actually means

Not that you never touch it again. It means the model can absorb new requirements without a migration: one decision point, one vocabulary for resources and actions, identity keyed stably, list queries filtered where the data lives, and enforcement at every entry point rather than in the interface. When a customer asks to share a single project with an outside auditor for thirty days, that should be a new relationship type and an expiry, not a design meeting.

If you are already past that point, the migration is doable and it has an order that works. Introduce the central decision function and route the easiest checks through it. Add decision logging immediately, because it tells you what the current rules actually are, which is invariably different from what anyone believes. Convert endpoints in groups, keeping the old check alongside the new one and logging where they disagree; the disagreements are your real specification. Then remove the old checks once the log is quiet. We have seen this take six to twelve weeks in a mid-size codebase, and the decision log is what makes it possible to do without breaking people.

What goes wrong, specifically

  • Building password reset and second factors yourself while expressing permissions as one string column
  • Keying users by email address, which changes, gets reassigned, and collides on case
  • Permission checks inline in every handler, no two written the same way
  • Enforcement in the interface only, with an endpoint that never checked
  • Fetching all records and filtering in application code, which breaks pagination and gets slower monthly
  • Background jobs running as the system, inheriting access the requesting user never had
  • Impersonation that grants administrator rights instead of reproducing the user's view
  • Only positive tests, so the missing check ships and a customer finds it

A six-week shape for getting this right

Identity and Access Pass

1
Write the twenty hardest access questions in plain sentences, including the ones you cannot answer today
Days 1–2
2
Pick the model those sentences require. Name resources and actions once, as a vocabulary
Days 3–5
3
Build the single decision point, with decision logging on from the first call
Week 2
4
Convert endpoints in groups, running old and new checks side by side and logging disagreements
Weeks 3–4
5
Make list queries permission-aware in the database. Give jobs explicit identities
Week 5
6
Negative test matrix, the CI check for unguarded endpoints, provisioning and the access review
Week 6

Step four is the one that makes the rest survivable. Running both checks and logging the disagreements turns a rewrite into a measurement, and the disagreements are almost always more interesting than anyone expected: rules nobody remembered, a role that was silently equivalent to another, an endpoint that had been open for a year.

Before you call it done

  • Authentication is delegated to an identity provider or an established library
  • Users are keyed by a stable internal identifier, never by email address
  • Session and token lifetimes are chosen deliberately and written down
  • Actions requiring immediate revocation use a live check, not a cached claim
  • Exactly one decision point, called from every entry point including jobs
  • List queries are filtered in the database, and the total count matches the rows
  • Every decision is logged with the reason, not only the request
  • Impersonation is logged, visually obvious, and grants no extra permissions
  • Service credentials are per-service, scoped, rotatable without downtime, and report last use
  • The test suite asserts denials, and CI fails on an endpoint with no decision attached

Bottom line

Spend almost nothing on authentication and almost everything on authorization. The parts that are hard to change later are the model, the singularity of the decision, and whether list queries can be filtered where the data lives. The parts that look intimidating — single sign-on, directory provisioning, second factors — are integrations with a known shape and a couple of weeks of work. Getting this proportion right at the start is worth roughly a quarter of engineering time later, and the tell that you got it wrong is simple: somebody asks who can see a record, and the honest answer requires reading the code.

Frequently asked questions

Should we build authentication ourselves or use a provider?

Use a provider or an established library. Password storage, reset flows, second factors, session handling and enumeration resistance are a large surface with well-known failure modes and no competitive value. Keep your own effort for authorization, which is specific to your domain and cannot be bought. The one authentication decision worth your own thought is session and token lifetime, because it determines how quickly a revoked access actually stops working.

How do we know when roles are no longer enough?

Ask whether two users holding the same role can legitimately see different records. The moment the answer is yes, permission depends on the relationship between the user and the object, and a role column cannot express it. Sharing, hierarchy, ownership and per-project collaboration are all signals of the same thing. Waiting adds inline checks that all have to be untangled during the migration.

Where do most permission bugs actually come from?

Missing checks rather than wrong rules. A new endpoint ships without one, a background job runs with system access, an export path bypasses the layer everything else goes through, or the interface hides a button while the server still accepts the call. That is why a single decision point plus a continuous-integration check that fails on any endpoint without an authorization decision catches more real defects than a large suite of rule tests.

What is the hardest part of a permission model to get right?

Listing. Deciding whether one user may open one record is easy; returning the first page of everything they may see, correctly paginated and sorted, is what determines the architecture. Filtering in application code after fetching everything breaks page counts and degrades every month. Either push the constraint into the query so the database can index it, or maintain a per-user set of accessible identifiers that is updated on change.

How long does it take to fix this in an existing codebase?

For a mid-size application, six to twelve weeks, done incrementally rather than as a rewrite. Introduce a central decision function, turn on decision logging immediately, then convert endpoints in groups while running the old and new checks side by side and logging where they disagree. Those disagreements are the real specification of your current rules, which is usually not what anyone believes it is.

1 business day response

Facing the permissions rebuild?

Send the access rules you cannot express today and a rough count of permission checks in the codebase. Our engineers will come back with the model that fits, the migration order, and what we would leave alone — or take the work as a scoped engagement. Email bo@precisionfederal.com.

Email an engineerCapabilitiesMore insights →
IdentityAccess ControlBackend SystemsApplication Security