Skip to content
← All notes
TechnicalSeptember 22, 202611 minute read

Verifying Tokens Without Calling Accounts Every Time — Part 2

Building a Permission System for Users and Services — Part 2

A user logs in, selects Organization A, and receives organization context after Accounts checks membership. Inventory verifies the token and checks access. Excalidraw diagram for Part 2.

In Part 1, we gave users and services identities that fit the same token structure. That helped us keep one endpoint for a resource operation while applying the right rules to each caller.

But a common token structure did not answer every question. Who should verify the token? How would Inventory know which organization the user was working in? What should happen when that user's membership changed?

We arrived at those questions one after another. The answer to one made the next easier to see.

In this article, I'll be using sample identities and claim names to explain the model. Follow one example user, Ada, who belongs to Organization A and Organization B. She wants to view an item in Organization A. We will keep returning to that request as the design develops.

We first expected Accounts to verify every token

Our first plan was straightforward. Ada would log in and receive a token. When she sent it to Inventory, Inventory would ask Accounts whether it was valid.

Ada's client -> Inventory: request an item, with a token
Inventory -> Accounts: verify this token
Accounts -> Inventory: verification result

At first, I was comfortable with the traffic this could generate. Accounts was one of the few services we wrote in Rust. I expected it to handle a large share of requests across the system, and that expectation influenced the language choice.

Then I thought about what happened when Accounts was unavailable.

Inventory could be running. Its database could be healthy. Ada could have a valid token. Yet Inventory would still need Accounts before it could accept her request.

Capacity did not solve that problem. A fast verification service was still a dependency for every caller that used it.

I wanted Inventory to verify an issued token for itself. Accounts would still issue tokens, but accepting each token should not require another verification request.

That raised the next question: what would Inventory need to perform the check?

Local verification needed a different distribution of authority

My initial signing idea used a secret key. With an algorithm such as HS256, the same secret creates and verifies the message authentication code. We could therefore give the secret to each service and let it verify tokens locally.

But think about what we would be giving it. A service holding that secret could also create tokens that passed the same check. Verification would come with issuing authority. HMAC for JWTs

I did not want that distribution of trust.

Our services run separately. One service can have a vulnerability even when the others are healthy. I wanted a compromise in that service to have a limited effect. Giving every verifier the signing secret would work against that goal.

While reading about JWTs, I looked at asymmetric signing. It gave us two different keys with different responsibilities:

Key Responsibility
Private key Accounts uses it to sign issued tokens.
Public key A receiving service uses it to verify the signature.

We chose RS256, which uses RSA signatures with SHA-256. Accounts keeps the private key. Inventory can hold the public key without gaining the ability to issue a token as Accounts. This is signing, not encryption; the token's claims remain readable. RS256

Service permissions support the same goal. A service needs grants for its own operations. Its identity should not carry unrestricted access merely because we operate it. We will develop those grants in Part 3.

There is still a key-distribution step. Accounts publishes public keys through a JSON Web Key Set, or JWKS. A verifier uses the token header's kid to select a candidate from that trusted set. It then checks the signature. JWK Sets

We cache those public keys for reuse. The verifier also checks the expected issuer, audience, accepted algorithm, and expiry. Reading a subject from the payload is not enough to trust it.

With verification material available, Inventory can now establish Ada's identity locally. Let us follow her request a little further.

We knew the user, but which organization were they working in?

The token's subject identifies Ada. In a claim fragment, that looks like this:

{"sub": "example-user-ada"}

Inventory verifies the token and knows who sent it. Ada then asks for an item in Organization A.

We still need to establish the organization context for that request.

Ada belongs to two organizations. Her identity stays the same when she moves between them, but the data she should reach changes. Knowing Ada's identity cannot tell Inventory which organization this particular request should use.

Suppose the client sends an organization ID beside the request. That tells Inventory what the client selected. It does not prove that Ada belongs to the selected organization. A request parameter is an input to check, not a membership decision.

This is where I had to separate identity from context. The subject tells us who the caller is. The organization tells us where the user is working. Neither one, on its own, tells us whether the user may read an item.

We needed to establish that context before the handler queried organization data.

Checking membership on every request brought back a dependency

The obvious next step was to ask Accounts. It owned organization membership, so Inventory could check whether Ada belonged to Organization A before retrieving the item.

That was a valid way to make the decision. But follow the request again. Inventory would verify the token locally, then wait for Accounts to confirm membership.

We had removed the verification call and introduced a membership call in its place.

I wanted to avoid repeating that lookup for every resource request. If Ada opened ten items while working in the same organization, the selected context would be the same for all ten.

Could Accounts establish that context earlier and give Inventory something it could verify?

We already had the mechanism for carrying an issuer's claims: a signed token. That was the point where adding an organization to the token started to make sense to me.

The decision was about moving the membership check. Accounts would perform it when issuing organization context, and the receiving service would use the verified result during resource access.

We moved the membership decision to token issuance

The login model I started with gave the user an account-level token before an organization was selected. For Ada, its relevant claims look like this:

{"sub": "example-user-ada", "organization_id": null}

That token establishes an account identity. It does not supply the organization context required to work on Organization A's inventory.

I wanted that distinction to be explicit. Logging in should not give one token an implicit right to operate in every organization the user might belong to.

Now let Ada select Organization A. Her client asks Accounts for a session in that context. Accounts validates the existing session, checks her membership in A, and issues tokens for that organization.

The access token now carries this fragment:

{"sub": "example-user-ada", "organization_id": "example-org-a"}

The important part is the check before issuance. We do not trust the organization because the client requested it. We trust the signed context because Accounts established it for the verified user.

Here is the decision in pseudocode. These helpers describe responsibilities; they are not SDK calls or complete endpoint code.

def select_organization(session_credential, organization_id):
    session = validate_user_session(session_credential)
    require_current_membership(session.user_id, organization_id)

    return issue_user_token_pair(
        user_id=session.user_id,
        organization_id=organization_id,
    )

If membership cannot be established, this path does not issue the organization token.

Notice that Ada does not have to enter her password again. The client uses her existing session to request the selected context. We change the token used for the work without turning organization selection into another login.

The diagram follows that original account-level login model. It separates the selection step so that you can see where the membership decision occurs.

Ada logs in, receives an account-level session, selects Organization A, and receives an organization-scoped token after Accounts checks membership. Inventory then verifies the token locally and applies permissions and organization-scoped data access.

Accounts establishes the organization context at issuance. Inventory uses that verified context on resource requests.

This separation is the principle to keep in view: establish the user session, authorize the selected organization, and then issue its context. It does not require every login interface to show a separate organization-selection screen.

One token now carries one organization context

Follow Ada's new token into Inventory. The verifier checks it using the trusted public key and validation policy. The request layer then takes Organization A from the verified claims.

The handler uses that organization when it looks up the item. It also checks whether Ada has the permission required for the operation.

In pseudocode, the user request looks like this:

def read_item(access_token, item_id):
    claims = verify_user_access_token(access_token)
    organization_id = require_organization_context(claims)
    require_item_read_permission(claims.sub, organization_id)

    return items.get(
        id=item_id,
        organization_id=organization_id,
    )

The permission check and the final query do different work. The first decides whether Ada may read items in that context. The second limits the lookup to that organization's data.

Now suppose Ada changes to Organization B. Her identity has not changed, and she may belong to B as well. But her token for A still carries A. Changing a request parameter cannot turn it into a token for B.

The client requests the B context from Accounts, which checks membership before issuing it. Subsequent work in B uses that context. The account-level token with a null organization also cannot substitute for it at an endpoint that requires an organization.

This was the narrower authority I wanted. A token carries the context selected for its work, instead of leaving each receiving service to discover the user's organizations.

We are following a user session here. Service callers retain their own identity and grant rules. Their target organization context does not make them users or give them organization membership.

So far, we have established the context at issuance. We still need to decide how long the client can keep using it.

Membership can change after we issue a token

Suppose Accounts checks Ada's membership in A and issues the token. Later, an administrator removes her from A.

A signature cannot update itself when a database record changes. It proves that the issuer signed those claims. It does not turn the token into a live membership query.

That was why I chose short-lived access tokens. The expiry limits how long one issued token can be used. But I also had to consider what happened at the end of that period.

We did not want Ada to enter her password whenever an access token expired. A refresh token lets the client ask Accounts for another access token within an existing session.

There was an important consequence: if renewal simply copied the old organization claim, a short lifetime would keep producing short-lived tokens for the same old context.

I treated renewal as another access decision. Before Accounts carries an organization into a new user access token, it checks current membership. If Ada no longer belongs to A, the old claim does not authorize another token for A.

The user renewal rule looks like this in pseudocode:

def renew_user_access(refresh_token):
    session = validate_user_refresh_token_and_record(refresh_token)
    organization_id = session.organization_id

    if organization_id is not None:
        require_current_membership(session.user_id, organization_id)

    return issue_user_access_token(
        user_id=session.user_id,
        organization_id=organization_id,
        refresh_id=session.jti,
    )

The validator checks the token and its stored session record. The membership helper must establish membership before issuance can continue. An account-level session has no organization to carry forward; it remains account-level.

This is also where token identifiers become useful. Each token has a jti, its identifier. I wanted an access token to refer to the refresh token that produced it. In these examples, I call that reference refresh_id.

The refresh token might have jti: example-refresh-a. An access token produced from it has its own jti and carries refresh_id: example-refresh-a. Accounts can use the stored refresh record to manage whether renewal remains allowed.

That reference does not revoke an access token by itself. Renewal policy controls further issuance. An already-issued access token still follows its validation rules, while the receiving service applies the operation's authorization rules. Token expiry and current permission checks have separate jobs.

We can now put the claims together without introducing fields before their purpose is clear:

{
  "sub": "example-user-ada",
  "iss": "https://identity.example.test",
  "aud": "svc:example-inventory",
  "organization_id": "example-org-a",
  "jti": "example-access-a",
  "refresh_id": "example-refresh-a",
  "iat": 1800000000,
  "exp": 1800000120
}

This is a sample access-token payload, not a complete signed token. The identifiers and interval are illustrative.

Claim Purpose in this example
sub Identifies Ada.
iss Identifies the trusted issuer.
aud Identifies the intended receiving service.
organization_id Carries the selected organization context.
jti Identifies this access token.
refresh_id Refers to the refresh token that produced it.
iat, exp Record issuance and expiry times.

The JWT standard defines the registered claims. organization_id and refresh_id are application-specific example names. JWT claims

What we gained, and what still needs an access decision

We started with Inventory asking Accounts to verify every token. We then separated issuing authority from verification and moved the user membership decision to organization-token issuance and renewal.

Each choice reduced a repeated decision in the resource request path. Each also gave us something else to manage.

Public keys need distribution and caching. Rotation must keep the necessary verification keys available for valid tokens. Local verification depends on having that trusted material.

Login, organization selection, and renewal still depend on Accounts. Shorter access-token lifetimes require more frequent renewal, so their value has to be considered beside traffic and availability.

I accepted those costs because they put the responsibilities where I wanted them. Accounts establishes identity and issues organization context. Inventory verifies that context and uses it to constrain resource access.

We have deliberately left one call in the pseudocode unexplained: the permission check. A valid token for Organization A does not tell us whether Ada may read, edit, or delete its items.

That is the next part of the design. Part 3 introduces the permissions and groups that let us answer that question for both users and services. Read it here

Continue the conversation

Comments

Write a comment

Checking this browser…

Use 1 to 80 characters. We save this name with your verified browser session. To choose another name, select Forget this browser.

Plain text only. Maximum 500 characters. Comments cannot be edited. 0/500

Waiting for the security check…

No comments yet. You can start the conversation.