This series documents an implementation of authentication and authorization in a microservice architecture. I'll be walking you through my thought process, implementation details and outcome of each step. The purpose is to equip you with a good understanding of authentication and authorization in complex systems and show you one way to address it.
The Accounts service had a straightforward job: handle authentication, authorization, and user access across Leysir.
At least, that was the plan.
Inventory was already one of the first services we had built. It needed permissions too. Someone who managed stock would need different access from someone who only viewed it. We knew we wanted groups and permissions. What we had not settled was how that model would work when services also made requests.
That became the more interesting part of the problem. Once one service needs something from another, the person using the application is no longer the only caller we have to think about.
Let us work through how that changed the design.
The people and identifiers in this series are teaching examples. We will follow Ada and Bayo, who work in Organization A. Ada also belongs to Organization B. Inventory uses the sample permission namespace catalog. Later, we will add a fictional service called Example Sync. These examples explain the architecture without reproducing deployment settings.
We knew people would need different access
Start with something small. Ada needs to change item details. Bayo needs to view those details but should not change them.
We could describe that requirement without deciding anything about JWTs, middleware, or manifests. We needed to identify each person and decide which operations that person could perform.
At this stage, those are separate decisions.
If Bayo signs in successfully, we know that the request belongs to Bayo. That does not mean he can edit an item. If Ada has permission to edit items, that permission is useful only after we know that the request actually belongs to Ada.
Authentication establishes the caller's identity. Authorization decides whether that caller has authority for the requested operation.
Accounts was supposed to own identity and the information needed for those access decisions. Inventory owned items, stock, and the rules for changing them. I wanted each service to keep that responsibility.
Here is the first request to keep in view:
Ada's client -> Inventory: edit an item
Inventory cannot proceed merely because the request includes Ada's name. It needs a way to trust the identity and then check access.
We started with the resource endpoints while we worked through this model. That is why I do not describe the finished architecture as something we designed in one sitting. Each requirement exposed another decision.
Verifying a user introduced another caller
Our first token-verification proposal was straightforward. A user would sign in, receive a token, and send it with a resource request. The receiving service would ask Accounts whether that token was valid.
Notifications was one of the services we expected to make this call. We can follow the same proposed arrangement using Inventory:
Ada's client -> Inventory: edit an item, with Ada's token
Inventory -> Accounts: verify the supplied token
Accounts -> Inventory: verification result
Now stop at the second line.
Ada made the request to Inventory. Inventory made the request to Accounts. The token being inspected describes Ada, but the party asking for verification is Inventory.
Those are two identities with two different jobs in the same flow.
Accounts needs to decide whether Inventory may use the operation that inspects access information. Separately, Inventory needs to decide whether Ada may edit an item. Granting Inventory permission to ask a question cannot make the answer about Ada automatically positive.
That distinction mattered to me. It would be easy to concentrate on the user's identity and treat the request between services as an implementation detail. But it is still a request crossing an application boundary.
Someone has to be accountable for it.
You may already be thinking about local token verification. We will get there in Part 2. At this point, the proposed verification call helped reveal the service-identity problem. Even after removing that particular call from the resource path, services still have other reasons to communicate.
Why being inside the network was not enough
We could have trusted requests between our services without giving each service an explicit set of permissions.
They were our services. We controlled their deployment. It was tempting to treat the internal network as sufficient evidence that a request should be allowed.
I did not want our application rules to depend on that assumption.
A service can have a vulnerability while other services are healthy. If access to that service automatically provides unrestricted access elsewhere, its responsibility becomes much larger than the job we intended it to do.
Think about Notifications. Sending messages does not require authority to change every inventory item or register every service. We need to express what it may do even though we operate it.
Network controls remain useful. They can restrict which connections reach an application. The application still needs to decide whether an accepted caller may perform a particular operation.
This also answers a question I had about deployment tools. Docker Compose and Kubernetes can change how services are placed, connected, and managed. Those choices do not supply our item-editing policy. A deployment platform cannot infer what Ada's job permits, or which inventory operations Notifications needs.
I wanted the application model to keep working when the deployment arrangement changed.
That meant a service needed authority of its own. We could then inspect its access, change it, and explain why it could call one operation but not another.
This did add work. We needed service identities, credentials, and permission assignments. The simpler trusted-network approach avoids some of that setup. I accepted the extra work because the resulting boundaries match the responsibilities we wanted services to have.
Separate callers did not have to mean separate endpoints
The next question was how to expose an operation that both a person and a service could use.
For a teaching example, suppose Ada edits an item through the application. A scheduled process also edits item details from an approved source. Both requests eventually need to validate the change, find the correct item, and apply the same Inventory rules.
One option is to create separate user and service endpoints.
That can be reasonable when the operations themselves differ. An interactive edit and a bulk import might require different inputs, validation, or response behavior. Different operations can deserve different routes.
The distinction I did not want to force was a second route only because the caller was a service.
Even if both routes called one shared business function, we would still maintain two request contracts. We would need to keep their validation, error behavior, documentation, and tests aligned where the operations were supposed to match.
And two routes would not remove the need to authenticate their callers. A URL containing the word "internal" is not an access decision.
I wanted to keep one resource operation and retain enough information about the caller to choose the correct policy.
The request therefore needed to tell us more than "authentication passed." It needed to carry the verified identity, the caller type, and the context that the operation required.
If the service and user needed different business behavior, that behavior could still branch on the verified context. Sharing an endpoint does not require us to pretend the callers are identical.
Services could have identities too
Once I treated a service as a caller, the common structure became easier to see.
A user has an identity that persists across requests. A service can have one as well. The way they authenticate can differ while the result supplies a common kind of information: who is making this request?
I looked at the standard JWT claims with that in mind. The sub claim identifies the subject of the token. That subject does not have to be a human user. JWT subject definition
Here is Ada's subject in the sample vocabulary:
{"sub": "example-user-ada"}
And here is a service subject:
{"sub": "svc:example-sync"}
These are small claim fragments, not usable tokens. The svc: prefix is our example of an application convention for a service identity. It is not a requirement imposed by JWT.
We do not trust a subject simply because we can read it. The receiving service first verifies the token and checks the claims required by its validation policy. Only then can it use the subject to establish caller context.
That order matters. A service-looking string in an unverified payload is still just a string supplied by the caller.
We chose a common token structure because the information needed at the receiving boundary was similar. Both callers need an identity. Both tokens need validation. Both requests need an access decision.
The common structure lets us reuse those responsibilities without forcing identical login methods. A person can authenticate through a user session. A confidential service can authenticate using its own credentials. Accounts produces the identity-bearing token used for the resource request.
The credential proves access to the identity. The token carries claims issued for a request context. The permissions determine which operations are allowed.
Keeping those ideas separate helped me avoid making one object responsible for everything.
One format still needs different permission rules
A shared token structure does not make a user grant interchangeable with a service grant.
For the item-read operation, our example vocabulary has two keys:
user:catalog:item:read
service:catalog:item:read
The receiving endpoint selects the requirement for the verified caller type.
If Ada sends a valid user token, the endpoint evaluates the user requirement against Ada's grants. If Example Sync sends a valid service token, it evaluates the service requirement against that service's grants.
We will build the permission format in Part 3. For now, keep the relationship in view: the operation can be shared while the grant paths remain distinct.
The diagram follows those two caller types into one operation.

Shared token structure and resource logic do not merge the callers' authority.
Here is a preview in pseudocode. The helpers describe responsibilities that we will develop in later chapters:
def read_item(request, item_id):
claims = verify_access_token(request.token)
caller = identify_caller(claims)
context = resolve_permitted_context(request, caller)
require_permission(
caller=caller,
organization=context.organization_id,
operation="catalog:item:read",
)
return items.get(
id=item_id,
organization_id=context.organization_id,
)
The permission helper chooses the user or service requirement. The resource query then limits the lookup to the organization established for the operation.
These checks answer different questions. Permission to read items does not make every item in the database part of the current request's context.
Ada belongs to two organizations in our example. We still need to establish which one she is working in. Part 2 explains how we arrived at that boundary. The point here is that the common handler must preserve it.
Consider the outcomes. A valid identity without the required grant stops before the item operation. A permitted caller looking for an item outside the selected organization does not get that item through this query. Only a request satisfying both responsibilities can proceed.
That is more useful than a single "logged in" flag.
A service call is not automatically user delegation
Return to the verification flow from earlier. Inventory asks Accounts about Ada.
Inventory authenticates the outgoing call as Inventory. Ada remains the subject of the lookup. The service's permission to perform that lookup is different from Ada's permission to edit an item.
Now compare that with Example Sync doing scheduled work. It acts under its own service identity. It does not become Ada because Ada once configured the job or can view its status.
A workflow that must preserve a user's authority across several services needs an explicit design for that purpose. We should not silently replace the user with a more powerful service identity and describe the result as the user's access.
This series uses service-owned work when demonstrating service grants. When we demonstrate user work, we keep the user principal in view.
That distinction also helps with review. We can ask whether a given operation should run under a user's grants or under a service's own responsibility before choosing the token and permission requirement.
The shared format makes either identity understandable to the receiver. It does not decide whose authority a workflow ought to use.
Why I accepted Rust's maintenance cost
The original verification proposal also influenced our stack.
I expected Accounts to receive traffic generated by activity across the application. Login, token issuance, and access management would make it busy even if we reduced the verification calls later.
Rust was a good candidate for that workload in my judgment. This was an expectation about the service we were building, not a claim that a language automatically gives a fixed throughput advantage.
There was a practical cost.
I had written Rust for some time, but this was my first time putting it on such a critical production path. I was also the only Rust developer on the team. As someone in a lead role, I expected to write much of the first implementation and less of the code as the team grew.
Choosing Rust concentrated maintenance knowledge on me until other people could contribute.
Using a language the rest of the team already knew could have reduced that cost. It could also have made it easier to share work during the first implementation. Those are substantial advantages, particularly for a small team.
I accepted the Rust cost because I trusted the checks the language gave me. I wanted help enforcing invariants before the service ran and avoiding classes of memory and concurrency errors. That still leaves application logic, permission rules, and distributed behavior for us to get right.
My expectation was that a stable service would need less frequent intervention. So far, Accounts has needed less corrective work than the other services I have written in this system. That is my experience, not a controlled language comparison. My own experience with this kind of application also matters.
The initial implementation took longer while I assembled components that a more complete framework would have supplied. Knowing what middleware should do did not remove the work of writing it.
Once those pieces and patterns existed, development became easier. AI tools also became useful for continuing work within a structure I had already defined. Most of Accounts was handwritten. Clear boundaries made later assistance more useful.
I remain enthusiastic about Rust, and I would like more Nigerian developers to learn it. That enthusiasm was part of my architectural judgment. It does not remove the team's learning cost; it explains why I was willing to accept it.
The common model gave us the next set of questions
We expected more services and different kinds of organizations. I did not want every new service to restart the identity discussion.
The model now gives us a stable starting point. Accounts establishes identities. Services define and enforce operations. Users and services arrive through a common request structure while retaining different access rules.
We still have decisions to make.
How can Inventory verify a token without asking Accounts on every request? How does Ada select an organization? How do organizations assign permissions without asking us to add another hardcoded role? How does a new service declare its access?
Each question follows from the previous one. We will build those answers in order rather than ask the token format to solve all of them.
Part 2 starts with the verification dependency. We had given services identities. The next step was to decide what authority each service needed in order to trust an issued token for itself. Read it here

No comments yet. You can start the conversation.