Evolution of AuthN/AuthZ Architecture (5): Data Model and Operational Design for a Custom Authorization Server
This series is in five parts.
- Part 1: From Monolith to an Authentication Platform
- Part 2: API Gateway and Microservices
- Part 3: Advancing Client Authentication and Migrating Incrementally
- Part 4: Internal Structure and Reliability Design of the Authorization Server
- Part 5 (this article): Data Model and Operational Design for a Custom Authorization Server
In Part 4 I wrote that "the place to exercise from-scratch creativity is not the protocol, but the data model, operability, and integration." Part 5 is what that actually looks like. RFCs specify the on-the-wire behavior of a protocol, but they say almost nothing about what an AS remembers and how, who it authorizes for what, and how it lets all of that be managed. This is the territory you actually design when you build your own (or deeply customize a product), and it is also the territory where products differ most in their design philosophy.
The big picture: the four entities an AS manages
Let me draw a map of the data model first. At the core of an AS there are roughly these four.
- Client: the app's registration information—authentication method, redirect URIs, requestable scopes/audiences
- Resource Server (audience): the destination of a token. Surprisingly often not made subject to registration, but it should be (more below)
- Grant (consent): the fact that "this user allowed this client this scope"
- Token (especially the refresh token): a live credential issued on the basis of a Grant
From here I'll design these four in order. Stating the underlying principle up front: tokens are a volatile cache, and Grants are the durable fact, and referential integrity is anchored on the Grant—this is the single biggest claim of this article.
Modeling the Client
Do you separate internal and external clients?
This is the first fork in the road, and my answer is: "Separate them—but as a trust level, not a boolean."
Internal (first-party: apps your own company develops and operates) and external (third-party: apps from other companies or the developer community) differ in nearly every respect.
| Aspect | Internal client | External client |
|---|---|---|
| Consent screen | Commonly skipped (more below) | Required |
| Requestable scopes | Broad (including administrative ones) | Only those approved through review |
| Registration flow | Self-service by the dev team | With a review workflow |
| Rate limiting / revocation ops | Lenient | Strict, subject to emergency shutdown |
| Client authentication | Can enforce the organizational standard | Needs breadth in the methods supported |
The problem is that OAuth/OIDC specs have no standard concept of "first-party" (a First-Party Apps draft is progressing at the IETF). In other words this is territory each AS designs on its own, which is exactly why people tend to build it carelessly as a boolean like is_internal. But once operations begin, in-between cases inevitably appear: "internal but a separate business domain," "an acquired subsidiary's app," "external but a strategic partner for whom we want to relax review." If you design it as an enum of trust tiers and make "whether consent can be skipped," "the ceiling on requestable scopes," and "whether review is required" derivation rules from the trust level, then adding an in-between case is just adding data.
Audience permissions — which clients may obtain tokens for which RS
As noted in the addendum to Part 1, the aud of an access token holds the identifier of an RS. Naturally, then, you need to manage the permission of "which RS-destined tokens may client A request." An AS that doesn't design this implicitly falls into either "every client may request tokens for every RS" or "issue an all-inclusive token with no aud restriction." Both violate the principle of least privilege, and the latter means that a single stolen token can hit every API.
As a model this becomes a Client × ResourceServer × Scope permission matrix. The runtime decision looks like this:
Permissions of the issued token =
the client's permission (the ceiling reviewed at registration)
∩ the user's consent (Grant)
∩ the request in this specific call (scope, resource parameter [RFC 8707])
If you fix this "intersection of three sets" structure at the start, then every individual spec (scope, resource indicators, aud constraints during Token Exchange) can be organized as an input to this formula. Note that azp is an output-side claim used to record "which client obtained it" for a token with multiple audiences; the input to the permission decision remains the client_id.
Who owns the scope namespace?
It's often overlooked, but scope-name collisions really do happen. Once multiple RS teams start defining bare scopes like read, both the consent-screen display and the aud decision become ambiguous. I think it's best to enforce, as part of the AS's data model (Scope belongs to a ResourceServer), the convention that an RS defines its scopes and prefixes its own namespace (payments:read, accounts:write). Rather than operating a convention through documentation, it's stronger to create a state that cannot even be expressed in the model.
Resource Server registration and the "RS portal"
There's plenty of discussion about client registration, yet RS registration somehow goes unmentioned. But an RS is a legitimate subject of registration too.
- Managing uniqueness of audience values: issuing
audidentifiers and preventing collisions can only be done if the AS keeps a ledger - Distributing introspection credentials: in an opaque-token setup (Part 2), the RS is the one querying the AS, so the RS's own credential management is required
- The right to define scopes: as above, the scope namespace belongs to the RS. Adding a new scope should flow as an RS team's application
- The notification target for key/config changes: for key rotation or spec changes, you need a list that reliably reaches "every team that validates tokens." That is the RS ledger itself
In short, what an RS needs is not a flashy portal but a ledger and an application flow. If the target is internal RSes, this can be fully realized with Git-managed declarative configuration (Terraform, etc.) + CI review, and the same design philosophy of self-service and guardrail-style governance discussed in Part 2 applies directly.
Should you make a developer portal public?
Whether to build a developer portal for clients (self-service registration, documentation, dashboards) is decided almost entirely by whether you intend to build an external ecosystem.
- If you're opening to external developers, it's essential. Registration/review workflows, registering secrets and public keys (for private_key_jwt), managing redirect URIs, a sandbox environment, usage statistics—if you run all of this by hand over email, you can maintain neither the quality nor the speed of review. It's best to see the portal not as an appendage to the AS but as the UI of review as a governance process
- If it's internal clients only, prefer GitOps over a portal. For internal developers, client registration via a web UI tends instead to become a change path outside governance. Declarative management that passes code review is superior on both the audit-trail and change-management fronts
- Be cautious about opening Dynamic Client Registration (RFC 7591/7592) anonymously to the internet. DCR exists as a protocol, but an unreviewed registration endpoint can also become a factory for malicious clients. I think a realistic split is: a portal (interposing human review) for external-facing, and authenticated DCR for machine-to-machine automation
Modeling the Grant (consent) and designing the consent screen
What is the consent screen for?
Before designing, I want to make the purpose clear. The consent screen is neither authentication nor an authorization decision; it is a device for transparency toward the user. It's the place to confirm "we're going to hand your data to this third party—is that OK?" Reasoning backward from this purpose, you can derive the cases where it should and shouldn't be shown.
- For internal (first-party) clients it's common not to show it, and that's fine. The user is using "that service itself," and there is no additional information in the service accessing the user's own data. Just as Google doesn't show a consent screen for the Gmail app, showing it becomes a mere ritual of making people click "Yes," and through consent fatigue it even dilutes the effect of the truly important consent screens
- For external clients, always show it the first time. This is the boundary of data delegation—the very thing that OAuth's origin (the password anti-pattern in Part 1) sought to protect
- As an interesting in-between case, there's a defensible judgment to show it even for an internal client if it involves "providing data to a separate business domain." Organizationally it may be internal, but from the user's expectations it's close to third-party provision. The value of having multi-tier trust levels pays off here too
May you skip the consent screen if already authorized?
This is an excellent point of debate, and the answer is: "You may skip it if the already-consented scopes subsume the requested scopes. But there are preconditions."
Persist the Grant as user × client × set of approved scopes, and at authorization-request time:
requested scopes ⊆ Grant's approved scopes → skip the consent screen
otherwise → present only the delta scopes for additional consent (incremental consent)
This is the standard design (incremental consent), and the major IdPs behave this way. A design that shows consent every time looks conscientious at first glance, but in practice it breeds consent fatigue and users start approving without reading. I believe that the value of consent is preserved not by frequency, but by confining it to meaningful moments.
That said, skipping has preconditions.
- Client impersonation must be prevented. Skipping consent is grounded in "this client_id was approved in the past," so if client_id spoofing and loose redirect-URI validation (partial matching, etc.) exist, an attacker can impersonate an approved client and silently obtain tokens. Exact-match validation of redirect URIs and PKCE are the prerequisite infrastructure for running skips
- Respect the protocol's control parameters. When
prompt=consentarrives, re-present it even if already approved (the case where the client explicitly demands reconfirmation); letprompt=nonesucceed only when a skip is possible, and return aconsent_requirederror when consent is needed. These two are behaviors specified in OIDC, so don't override them with your own judgment - It must come paired with a means to revoke consent. If you persist consent and use it for skipping, a UI where the user can view a "list of connected apps" and revoke them is its counterpart. Persistent consent that can't be revoked hollows out consent, which was supposed to be a device for transparency
Referential integrity anchored on the Grant
Here we return to the claim from the opening. The biggest reason to hold the Grant as an independent entity is that it becomes the root that threads the chain of revocation. When a user revokes a connection, what should be deleted is the Grant, and the refresh tokens issued on the basis of that Grant must be revoked in a cascade. An AS that doesn't hold this reference (RefreshToken → Grant) in its model creates the state of "the connection was removed but the token is still alive"—a betrayal of the user's expectations.
Modeling tokens
Access tokens: as a rule, "don't store them"
In JWT form, the basic pattern is not to store access tokens on the AS (this is consistent with the availability design in Part 4). The motives for wanting to store them are revocation and auditing, but:
- For the revocation requirement, first consider whether "short lifetimes + a check at refresh time" (Part 1) can satisfy it. If immediate revocation is still needed, rather than storing all tokens, a jti denylist (record only what's been revoked, with a TTL equal to the token lifetime) is lighter
- For the audit requirement, satisfy it not by storing tokens but with a log of issuance events (to whom, which client, which aud/scope, which jti). A log can be made append-only and hard to tamper with, and this is actually the better fit for audit requirements
In opaque form you of course need a store, but don't store in plaintext. A token is a credential, so if a DB dump leaks, every user's session leaks. Store it hashed and look it up by hash when matching—treat it the same as a password.
Refresh tokens: the heart of state design
Refresh tokens are long-lived and powerful, so you must hold state on the server side, and there are many design points.
- Rotation and reuse detection (refresh-token rotation): issue a new refresh token on each use and invalidate the old one (recommended by the OAuth 2.0 Security BCP). What matters here is modeling the token family. If an already-invalidated old token is used, that's a signal of theft (either the legitimate client or an attacker used the older one), so revoke the entire family. This detection can't be implemented unless you keep the old token "retained as revoked" rather than "deleted," and bundle them by family_id. If you build on deletion, you can no longer distinguish reuse from "an unknown token"
- Binding to a session/device: if you hold a refresh token by
user × clientalone, you can't build "log out only this device." If you attach the session (device) identifier at issuance, it becomes the foundation for per-device revocation and features like a "list of logged-in devices." Adding this axis retroactively entails data migration, so it's well worth putting in from the start - Dual expiration: combining an absolute expiration (definitely expires N days after issuance) and an idle expiration (expires M days after last use) is the standard practice. With sliding alone, you get tokens that live forever as long as they keep being used
- Store as a hash, and record last_used_at: the storage principle is the same as for access tokens. In addition, recording the last-used time and origin is used both for investigating suspicious use and for idle expiration
To sum up, a refresh-token table roughly lines up as "token_hash, family_id, grant_id, session_id, client_id, user_id, issuance/absolute expiration/idle expiration, state (active/rotated/revoked), last_used_at." Each column corresponds to one of the revocation paths or detection paths above, and I feel it's fair to say that modeling tokens is designing revocation paths.
Summary: mapping design questions to answers
| Design question | This article's answer | Rationale |
|---|---|---|
| Separate internal/external clients? | Separate. Not a boolean but a trust level | In-between cases inevitably appear |
| Is audience management necessary? | Hold a Client × RS × Scope permission matrix | Preventing all-inclusive tokens; least privilege |
| What to provide for RSes? | Not a portal but a ledger and application flow | aud uniqueness, scope namespace, change notification |
| Build a developer portal? | Essential if building an external ecosystem; GitOps if internal only | Portal = UI of the review process |
| May you skip consent when already authorized? | Skip on scope subsumption; delta via incremental consent | Preventing consent fatigue. But predicated on exact-match redirect URIs, etc. |
| What about internal-client consent? | Skip as a rule. Show exceptionally when crossing business domains | Derived from the purpose that consent is a device for transparency |
| Store access tokens? | Don't. Revocation via denylist, auditing via issuance log | Availability and simplicity |
| The key points for refresh tokens? | References to family/grant/session, and hashed storage | Modeling = designing revocation paths |
To borrow Part 4's phrasing, these designs outside the protocol are the true substance of building your own. And every one of the questions, pushed to its conclusion, comes down to "can you accurately record the fact of who was allowed what (the Grant), and reliably drag along the derivatives (tokens) when that fact disappears?" Whereas the on-the-wire protocol handles the "start" of delegation, the data model handles the "end" of delegation—if you're building your own, I think it's best to design from how it ends.
