An opinionated decentralized authorization using JWT tokens and service mesh

KrishnaKumar
11 min readJan 18, 2024

Authorization is act of granting access/privileges to a resource. Authentication precedes authorization step to assert the identity of a particular user. Here we have used JWT tokens to provide a decentralized authorization for micro services.

JWT in short is self-contained authorization token issued by server post successful authentication of user credentials. The token is in encoded form with 3 portions. First portion is an encoded header, second one is encoded body, and the last one is encoded signature. Body can contain the claims for the token issued by the server during authentication step. While the header and body are simply encoded but not encrypted, can still be asserted for integrity using the signature portion on the server before serving the request.

The primary idea of decentralized authorization is to have authorization to be granular and lives within the service that owns the data/resource. In a way it is also called Native authorization pattern where the authorization code and service are written in same programming language.

Taking a step back, Identifying the right pattern for authorization can become confusing and challenging when there are many ways of implementation.

In our case, it’s an online shopping portal with only one group of users who are customers who can place/cancel orders and track delivery status. All we wanted to evaluate primarily as part of authorization was to check whether the user has access to the requested resource. Apart from token validation checks.

Here I would like to mention different authorization patterns we have evaluated. I would be illustrating the same using a sample set of microservices as shown below. We have started with extremely naive thinking and slowly moved to a maintainable solution with few comprisable tradeoffs.

The Setup

Sample Client Service Interaction

In the above diagram, we have 3 services.

Service A: Takes external requests and talks to downstream service.

Service B: Takes both external and internal requests and talks to downstream service C.

Service C: Serves the requests coming from different sources from the database.

Also, there is an external ingress which accepts requests from Client and an internal ingress which facilitates communication between services.

Points and Requirements for evaluation:

  • Standard JWT token for authentication. It has claim data of the “userId” with a pre defined expiry time.
  • Have to check if the user has access to requested resource.
  • Should allow some endpoints to be used for both internal and external purposes without compromising security.
  • E.g: Let’s say we need to fetch checkout for a client request. When it is called from outside, should have user Id along with checkoutId in the request. Whereas, when an internal service (like a retry/consolidation service) would like to utilize the same endpoint, it should be able to fetch using checkoutId itself without the need to send user Id. As these internal requests need not be initiated from the user itself.
  • Ability to restrict certain endpoints for internal consumption only.
  • Less cost of maintaining authorization policies.
  • Assume there is a dedicated service which does authentication.

Authorization approaches

Authorization on API gateway intercepting requests, responses & headers (Proxy/Gateway pattern)

  • Place some sort of API gateway in front of all services. All external traffic is routed through API gateway.
  • This will allow us to possibly centralize authorization to the Api gateway itself and all downstream services need not have concern of authorization.

Disadvantages include:

  • Approach is very much centralized with too much of authorization logic specific to different types of endpoints. This can suffer maintainability in the long run.
  • Also reading and parsing entire request/response/headers will have performance cost.
  • In case of intercepting response and doing authorisation will have adverse consequences and can fail miserably for mutation requests
  • Needed additional endpoints on each service to fetch user Id for a resource type by the API gateway.

Authorization to be performed on Service A (Variation of Native Authorization pattern)

  • Fetching user Id accessible for a given requested resource from Service C’s DB
  • Authorization is done in Service A where it doesn’t have direct access to the data, nor the service is owner of the data.

Disadvantages:

  • Have to create additional endpoints on service C for fetching userId for various resource types.
  • Also doesn’t feel natural to do authorization at a point where the service is clearly not owner of the resource.

Authorization on DB Query layer on Service C (Variation of Native Authorization pattern)

  • When a DB query is executed, we make sure user Id will always be part of where clause of the query.

This gives fine grained control on the resources being accessed, but have disadvantages like

  • Purely depends on developer awareness to not forget to add userId in to the where clause.
  • Authorization responsibility is leaking to query layer, and it is not very obvious.
  • Also doesn’t provide flexibility to differentiate requests from internal and external sources without sending that information all the way from handler to Query layer. This will eventually pave way to create endpoints with internal and external scopes.
  • Certain service may have actual data needed, but do not have userId mapping within same database to perform a join and use the where clause.

Authorization middleware at service C (Variation of Native Authorization pattern)

  • Service owning data can perform pre-authorization using middleware for each request handler.
  • Ability to turn off authorization checks for internal endpoints (more details on this below) and the logic stays in middleware and is not leaked to rest of the service layers.
  • Authorization is decentralized and lives in the corresponding service and thus enables more maintainability for the developers.

Disadvantages

  • Duplicated logic in multiple services unless explicitly pay attention to de-duplicate of logic across services. (E.g. If something is repeated, can be moved to an authorization policies library)
  • No single place to know all the authorization policies in the entire system.

Out of all the above approaches, we feel authorization middleware at service level is more approachable and maintainable given its advantages of being decentralized. In our case, given the number of user groups and accesses required for each group, we don’t anticipate the policy implementations exploding unmanageably. Also, this approach with the use of some additional marker headers can help to move this authorization from the first service (doesn’t necessarily own data) which receives the external request to the service that owns the data.

How did we implement?

We leveraged Istio service mesh and added an envoy filter which does the following.

  • Adds X-App-User-Id header on verifying the JWT token expiry and validity.
  • Adds X-App-Ingress-Source header with value as external if request originate from external ingress.
  • Also adds X-App-Authz-Mandatory header if request is originating from external ingress.
  • Also added tests to make sure above filters are not altered without knowing as it can potentially harm security.

What is the intent of these headers?

X-App-User-Id: Determines the User Id of the request which is parsed from JWT token.

X-App-Ingress-Source: Determines the source of request, value external determines that it’s a request from customer device.

X-App-Authz-Mandatory: Determines if authorization check needs to be performed on the receiving service.

Additionally, we created couple of shared middleware implementations to make sure these headers are honored while request moves from service to service. For the purpose of this article, we are going to have code snippets in Golang.

Parsing headers from incoming web request:

Below example is a middleware that parses x-app-authz-mandatory and x-app-user-id headers from incoming web request and add its it to the context object. As a standard go pattern, context object is sent as parameter to every function within code.

// middleware.go
package middlewares

import (
"github.com/gofiber/fiber/v2"
)

// PreAuthHeader middleware to add pre auth headers to context
func PreAuthHeader(c *fiber.Ctx) error {
c.Locals("preAuthHeaders", getPreAuthHeader(c.Get))
return c.Next()
}

func getPreAuthHeader(headerGetter headerGetter) map[string]string {
return map[string]string{
"x-app-authz-mandatory": headerGetter("x-app-authz-mandatory"),
"x-app-customer-id": headerGetter("x-app-customer-id"),
}
}

Add headers to outgoing web request:

Below example is a middleware that reads x-app-authz-mandatory and x-app-user-id from context and adds as additional request headers to outgoing web request from any service.

// RoundTripper implementation to be used by HTTP client
package roundtrippers

import (
"net/http"
)

// PreAuth RoundTripper to add pre auth headers
type PreAuth struct {
next http.RoundTripper
}

// RoundTrip implementation of round tripper with pre auth headers
func (trt *PreAuth) RoundTrip(req *http.Request) (resp *http.Response, err error) {
if headers, ok := req.Context().Value("preAuthHeaders").(map[string]string); ok {
for k, v := range headers {
req.Header.Add(k, v)
}
}
return trt.next.RoundTrip(req)
}

// Adding roundtripper while creating http client instance
func NewClientWithRoundTrippers(client http.Client) http.Client {
transport := http.DefaultTransport
if client.Transport != nil {
transport = client.Transport
}

preAuthRT := roundtrippers.NewPreAuth(transport)

return http.Client{
CheckRedirect: client.CheckRedirect,
Timeout: client.Timeout,
Transport: preAuthRT,
}
}

Above middleware's ensure both X-App-User-Id and X-App-Authz-Mandatory headers are propagated to all downstream services.

Below are the scenarios explaining how headers will be propagated:

1. Request originated from customer devices (E.g: Request to Service B directly)

E.g. Create order request received from client application.

When request is received from the customer device, it will pass through External ingress and will land to respective service. Envoy filter does the following:

  • Authorize and add X-App-User-Id by reading the JWT claims.
  • Adds X-App-Authz-Mandatory header to mark that the request needs mandatory authorization as it is originated from customer device.
  • Adds X-App-Ingress-Source header with value external to mark the request is routed through external ingress.

2. Request originated from internal service (E.g: Service B)

E.g: Get order request by order Id from internal service.

When request is originated from internal service like Service B, authorization is skipped as both X-App-Authz-Mandatory and X-App-Ingress-Source headers won't be available. However, its optional to add X-App-User-Id header as it doesn't dictate any mandatory authorization but acts as additional information to upstream service.

3. Request originated from customer device and served via an internal aggregator/proxy (E.g: Request to Service C routed via Service A)

E.g: Get invoice request when made from customer device. Invoice service can talk to order service to get details of the order.

When request is received from the customer device, it will pass through External ingress and Service A and will land to Service C. Envoy filter does the following:

  • Authorizes and adds X-App-User-Id by reading the JWT claims.
  • Adds X-App-Authz-Mandatory header to mark that the request needs mandatory authorization as it is originated from customer.
  • Adds X-App-Ingress-Source header with value external to mark the request is routed through external ingress

In this case,X-App-Authz-Mandatory header will be sent to upstream services by using the PreAuthRoundTripper middleware at Service A. This header will allow Service C to perform authorisation instead of Service A as Service C being the owner of the data.

  • However, X-App-Ingress-Source header is not propagated through.

Another point to note here is, Scenarios 2 and 3 above solves multi purposing endpoints for external and internal access.

Let’s take a case, “Get Address for an order” is called from customer device. It must return unauthorized (401) error when customer is not associated with order itself. Whereas when the same endpoint is used in the internal context to fetch address for the given order id shouldn’t bother about the userId.

Restrict some endpoints from external access:

Whatever best authorization strategy you can have, it may not be thought through and implemented on day 1. Since we also have some existing liability of some endpoints not suitable for authorization.

E.g. A proxy endpoint which is supposed to be accessible by internal services but do not have sufficient information to do authorization at the middleware.

X-App-Ingress-Source allows us to restrict external access to certain endpoints. X-App-Ingress-Source is not propagated to downstream services. This is marker header to know that the request is directly received from an external ingress. RestrictExternalAccess middleware if added to any handler, checks if the header is present and the value = externaland returns unauthorized response. This helps when you have endpoints that needs to be restricted to be called from public domain while the rest of them continue to be available. However, this header is expected to be used only as a stop gap solution.

// Implementation of the RestrictExternalAccess middleware
package middlewares

import (
"github.com/gofiber/fiber/v2"
"errors"
)

// RestrictExternalAccess rejects direct requests from external clients which have
// "X-App-Ingress-Source: external" header in the request
func RestrictExternalAccess(c *fiber.Ctx) error {
if c.Get("X-App-Ingress-Source") == "external" {
return errors.Unauthorized("This endpoint is not directly accessible from external ingress")
}
return c.Next()
}
// Example of how the RestrictExternalAccess middleware can be used


// ....
// ....
// Restricting external access to particular endpoint
app.Get("/profiles/:id", middlewares.RestrictExternalAccess, h.GetProfiles)
// Rest of the code about starting web server, registering handlers etc ommitted for brevity

Conclusion:

With Istio providing header injection, middleware providing support to implement authorization policies at each service level. We will have a decentralized authorization in place which allows high maintainability and also allowing to defer authorization to the service owning the data.

If you have reached till here, it means you may have few questions like — How are we going to handle if we have multiple groups of users? I think, the same Authorization middleware’s would have to be enriched to work based on claims which can be sent as separate header by authentication service once the token validation is successful.

Advantages:

  • Decentralized, the logic resides in the service that owns the data.
  • Authorization is moved to middleware instead of retaining it as part of the service code.
  • Helps to have custom logic on how to retrieve userId from each type of request especially if the userId is not standardized across the requests.
  • Maintainability is high.
  • Identifying the authorization logic related to particular resource access is available with the service itself.
  • Each authorization function for an endpoint is focused and granular and allows modification easily.
  • Highly extensible when new endpoints require new authorization.

Disadvantages:

  • Authorization policy is tied to the implementation of the service; thus re-usability can be low.
  • Cannot find all the policies in one place for the purpose of re-use and auditability.
  • Istio playing major role in adding request headers. Slight misconfiguration can cause the authorization to be skipped entirely. Hence having tests for envoy filter configuration is very much necessary.
  • Benefits of centralized authorization like caching can be low.

References:

--

--

KrishnaKumar

Go, Rust, Erlang, Java at work || Blogging, Open source tools, Embedded systems, Industrial robotics as hobby