When working on a product in its early stages, we usually make assumptions that are not necessarily going to be correct down the road when the product begins to evolve and grow. As engineers, we aspire to build complete, “bullet proof” products, and this aspiration often contradicts the business need to deliver fast, go to marker quickly, and continue building and adapting the product while running “on the ground”.

When we start building an MVP, it’s usually a monolith application, the code base is still relatively small, making it easier to develop, deploy, debug and test.

The Challenge

Let’s start with a bit of history: when the authentication mechanism was first implemented at Lusha, the process was handled at the service level. We created an npm package that handled it so that each call to the service triggered the auth code in that package and the request was either denied or allowed to go through.

As we started to scale, we needed a solution that would solve the issue of multiple teams working on the same code base, code being mistakenly overridden, painful merge issues, and other effects of coworking in a chaotic development environment. Like many companies before us, our solution was to split the monolith into microservices. The vision we had in mind was that our microservices would break down our product into separate domains, each of those managed by a small team responsible for it. The microservices needed to be highly maintainable, testable, self-deployed and loosely coupled.

Breaking our product into multiple microservices was a gradual process and took a while. In the meanwhile, the same authentication mechanism we had in the original node package was being used for all our backend services that we’re consuming that package.

 

In this type of authentication, each endpoint is responsible for the authentication of the request.

The Pros:

  • Trust is defined at every border, allowing for a better definition of trust zones when necessary.
  • A system that enables different authentication scenarios based on data types.

The Cons:

  • Unnecessarily repeating authentication.
  • Unnecessary load on critical infrastructure, leading to availability issues.
  • Changes in the package made for even one service will affect all other services consuming the package.
  • Force triggering the deployment of all services to avoid versioning issues.

If the balance of pros to cons listed above is not enough to convince you that this solution has too many issues, think about what will happen when we add scaling issues to the equation. It was clear we needed a different solution.

Instead of an auth package, we wanted a centralized service that will handle authentication. But we needed to decide where we wanted to locate the service in our architecture. One option was to let the request hit the services, only this time each service will proxy the request to the authentication service. Something like this:

Such a solution would have all the benefits of the previous one, and it requires a small change to the current architecture to boot. Indeed, all we needed to do was define a contract between the service that receives the request and the authentication service and implement an API call (could be in the form of a package again). While introducing only a minimal change to our architecture thereby making it a good solution,  this scenario would lead to the node package acting as a proxy instead of implementing authentication logic, making most of the cons detailed above still applicable.

So what’s the solution?

We leverage the authentication service so it will get the requests before they hit our back-end services, unauthorized calls are rejected.

Our auth service will be responsible for creating and validating the jwt tokens. we can use two tokens that will be saved internally in a fast key-value DB like Redis.

A user token will be used to identify the user, with a relatively short life span, and a refresh token will have a longer life span and will be used to seamlessly re-authenticate the user once his user token expires.

 

Why is this solution better?

  • We no longer need to maintain a package that is shared with all backends.
  • We can reduce the load on the backend and improve performance by adding cache to the authentication service.
  • It reduces the burden on the internal services, if the request is passing between 5 internal servers, only 1 authentication is performed.
  • Back-end services are agnostic to changes in the authentication service.
  • Any URL that is intended to be accessible without authentication, would need to be whitelisted. This easily can be reviewed for correctness by automated tests.
  • Session management becomes easier to control, it’s now possible to terminate a user session simply by deleting his jwt tokens.

Our chosen solution has its downsides too. It creates a bottleneck that should be taken into account if you aspire to scale. Also, it leaves internal systems unauthenticated. There are ways to manage this (such as internal HTTP headers or mutually authenticated protocol exchange), but when you look at the big picture this should also be taken into account. All in all, for us, this solution has proven better than the previous methods I started off with. It’s definitely something to think about if you’re having issues with your authentication process.

4.3 3 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments

Developer? Join Lusha - Apply today!