Secure and Scalable Authentication in Microservices

Yacine PetitprezYacine Petitprez
10 min read

In a microservices architecture, authentication and authorization present unique challenges. How do you ensure secure access across distributed services? How do you maintain consistent permissions without duplicating logic? How do you balance security with performance? This article explores our solution: the Auth Context pattern.

The Challenge of Authentication in Microservices

When we designed our microservices architecture, we faced several authentication challenges:

  1. Distributed Security: Each microservice needs to verify user identity and permissions independently.

  2. Performance Concerns: Checking permissions for every request could create bottlenecks.

  3. Consistency: Authorization rules needed to be applied uniformly across services.

  4. Flexibility: Different resources require different access patterns (all users, own data only, team data, etc.).

  5. Statelessness: Services shouldn't maintain session state between requests.

Our Solution: The Auth Context Pattern

Rather than reinventing the wheel for each service, we developed a standardized approach using what we call the "Auth Context" pattern. This pattern combines JWT tokens with a context object that encapsulates all authorization information.

The Auth Context Object

At the heart of our authentication system is the auth_context object. This object:

  • Encapsulates user identity and metadata

  • Carries permission information (role & rights)

  • Provides methods to check authorization

  • Supports custom scopes for fine-grained access control

# Example of how auth_context is used in a repository
def scoped(action)
  auth_context.can!(action, "iam:people") do |scope|
    scope.all? { table }  # Admins can access all people
    scope.own? { table.where(id: auth_context.metadata[:person_id]) }  # Users can access their own data
  end
end

The auth_context is created on every request to the application and propagated throughout the call chain. This ensures that authorization checks are consistent and that every part of the system has access to the same security context.

Resource, Action, and Scope: The Three Dimensions of Authorization

Our authorization model is built around three key concepts:

1. Resource

A resource represents an entity or collection that can be accessed. Resources are typically named using a service prefix followed by the entity name, such as iam:people or workflow:projects.

This naming convention helps maintain clarity in a microservices environment, where similar resources might exist in different services. The service prefix makes it clear which service owns the resource.

2. Action

Actions represent operations that can be performed on a resource, such as:

  • read: Viewing or listing resources

  • write: Creating or updating resources

  • delete: Removing resources

  • Custom actions specific to certain resources

3. Scope

Scopes define the boundaries of access within a resource. Common scopes in our app include:

  • all: Access to all instances of a resource (typically for administrators)

  • own: Access only to resources owned by the current user

  • by_ou: Access to resources within the user's organizational unit

  • my_team: Access to resources for the user's team members

  • Custom scopes for specific business rules

The combination of resource, action, and scope creates a flexible and powerful authorization system. Rights are defined in the format service:resource.action.scope, such as iam:people.read.own (permission to read one's own people records).

Custom Scopes: Beyond Binary Authorization

Traditional authorization systems often provide binary yes/no decisions. Our system goes further by supporting custom scopes with associated data.

Custom scopes allow us to pass additional context that can be used to filter resources. For example, a user might have access to projects, but only within certain organizational units. The organizational unit IDs can be included in the token as custom scope data.

# Example of custom scope usage
scope.by_ou? do
  ou = auth_context[:ou]  # Get the organizational unit from the custom scope
  auth_context.reject! unless ou
  table.where(organizational_unit: ou)  # Filter resources by organizational unit
end

This approach allows for dynamic, data-driven authorization decisions without requiring additional database queries to fetch permission data.

JWT Tokens: Secure, Stateless Authentication

Our authentication system uses JSON Web Tokens (JWT) with ECDSA signatures to securely transmit authentication information between services.

Why ECDSA Signatures?

We chose ECDSA (Elliptic Curve Digital Signature Algorithm).

By using asymmetric Cryptography, only the IAM service holds the private key needed to create tokens, while all other services have the public key to verify tokens. This ensures that only the IAM service can forge new tokens, reducing attack surface.

ECDSA verification is also efficient, minimizing the performance impact of token validation.

The token payload includes:

  • User metadata (ID, person ID, organization ID)

  • Role information, which maps to a list of rights (explained later)

  • Custom scopes for fine-grained access control. Those scopes are stored in database and edited by administrators for each user.

  • Expiration time

Token Lifecycle: Short-Lived Tokens with Refresh

To balance security and user experience, we implement a two-token system:

  1. Auth Token: Short-lived (15 minutes) JWT token used for authentication

  2. Refresh Token: Long-lived token used to obtain new auth tokens without requiring re-login. Refresh tokens are using nonce to prevent replay attacks and if the token is shared, it will force the user to re-login, invalidating stolen refresh token.

  3. At the frontend level, we automatically regenerate a new Auth Token before a call to the backend, by cross-checking the expiration time. So the quick expiration time is completely transparent for the final user experience.

This approach provides several benefits:

  • Security: The primary auth token has a short lifespan, limiting the window of opportunity if a token is compromised.

  • Convenience: Users don't need to log in frequently, as the refresh token can silently obtain new auth tokens.

  • Revocation: Refresh tokens can be invalidated server-side (by editing nonce) if needed, effectively logging out all user sessions.

Role-Based Access Control

The auth_context is tightly integrated with our role system. Roles are defined in YAML files and loaded into memory at service startup.

# Example role definition
hr_manager:
  title: "HR Manager" # For frontend integration
  rights: # Follow resource.action.scope format
    - "iam:people.read.*"
    - "iam:people.write.by_ou"
    - "office:employees.read.all"
    - "$iam_access_self"  # Include template of common rights.

The role system includes three types of roles:

  1. User roles: Control access for regular users through the front-end

  2. Service roles: Used for micro-service communication (RPC call). IP-based access control is also implemented.

  3. API roles: Used for external API access, with multiple profiles and a permission-a-la-carte system.

Why This Approach Works Well

Our auth_context pattern provides several key benefits:

1. Consistent Authorization Across Services

By embedding the auth_context in every repository's scoped method, we ensure that authorization checks are applied consistently throughout the system. This prevents accidental security holes where a developer might forget to check permissions.

def scoped(action)
  auth_context.can!(action, "workflow:projects") do |scope|
    # Authorization logic here
  end
end

Security first, the CheckAuthenticationHandler middleware ensures that every request verifies the auth_context, raising an error if the context hasn't been checked, and preventing developers from shipping code without proper authorization checks.

raise Error::Authorization,
      "The authorization context hasn't been checked during the call. " \
      "Please ensure that you call `auth_context.can!` " \
      " or mark the context as checked with `auth_context.mark_as_checked!`"

What about publicly accessible endpoints? A special auth_context with anonymous role is built, allowing for custom logic to handle these cases.

2. Performance Optimization

Our approach minimizes database queries for permission checks. Instead of looking up permissions for each request, the necessary information is embedded in the JWT token.

For custom scopes that require additional data (like organizational unit IDs), this data is included in the token, eliminating the need for additional database queries during authorization checks.

3. Separation of Concerns

The auth_context pattern cleanly separates:

  • Authentication: Verifying user identity (handled by the code generating the JWT token)

  • Authorization: Checking permissions (handled by the auth_context)

  • Business Logic: Implementing application features (handled by services)

This separation makes the code more maintainable and easier to reason about.

4. Flexibility for Different Access Patterns

Different resources require different access patterns. Our scope-based approach allows for flexible authorization rules:

  • Administrators might need access to all resources

  • Managers might need access to resources within their organizational unit

  • Regular users might only need access to their own resources

The auth_context pattern accommodates all these patterns with a consistent API.

5. Resource-Based vs. Endpoint-Based Security

When designing our authorization system, we made a deliberate choice to implement resource-based security rather than endpoint-based security. This decision had significant implications for our architecture:

Resource-Based Security focuses on controlling access to data entities (like people, projects, or documents) regardless of how they're accessed. Permissions are defined in terms of resources, actions, and scopes.

Endpoint-Based Security controls access to specific API endpoints or routes, defining who can call which URLs or methods.

Why We Chose Resource-Based Security

We opted for resource-based security for several compelling reasons:

  1. Consistency Across Access Methods: The same authorization rules apply whether data is accessed via REST API, GraphQL, WebSockets, or internal service calls.

  2. Separation of Concerns: Authorization logic is decoupled from API design, allowing each to evolve independently.

  3. Data-Centric Protection: Security is applied directly to the data layer, preventing authorization bypasses through new or overlooked endpoints.

  4. Reusability: The same authorization rules can be reused across different endpoints that access the same resources.

  5. Alignment with Domain Model: Resource-based security aligns naturally with our domain-driven design approach.

  6. Role-Agnostic Authorization: Our system is designed to be completely role-agnostic at the implementation level. We never use role names to conditionally perform any action or scope. Instead, we rely solely on the rights and scopes present in the auth_context. This is a critical design principle that allows us to:

    • Add new roles without changing code

    • Modify role permissions without deployment

    • Avoid hard-coded role checks that create maintenance nightmares

    • Support dozens of different roles without complexity explosion

Trade-offs and Considerations

This approach isn't without challenges:

  1. Implementation Complexity: Resource-based security requires more upfront design and consistent implementation across repositories.

  2. Granularity Challenges: Some operations don't map cleanly to CRUD actions on resources and may require custom actions.

  3. Performance Considerations: Applying fine-grained permissions at the data layer can be more resource-intensive than simple endpoint checks.

  4. Learning Curve: Developers need to understand the resource-action-scope model rather than simpler role-based endpoint access.

Real-World Example: Project Member Repository

Let's look at a real-world example from our codebase:

def scoped(action)
  auth_context.can!(action, "workflow:project:members") do |scope|
    scope.all? { table }  # Admins can access all project members

    scope.by_ou? do       # Access by organizational unit
      ou = auth_context[:ou]
      auth_context.reject! unless ou
      Service::TableQuery.by_related_ou(
        table, ou, related_table: :projects, foreign_key: :project_id
      )
    end

    scope.by_projects? do  # Access only members of specific projects
      table.where(
        Sequel.lit("project_id IN ?", auth_context[:project])
      )
    end

    scope.my_organization? do  # Access members in user's organization
      organization = auth_context[:organization]
      auth_context.reject! unless organization

      frag = <<-SQL
        EXISTS (
          SELECT 1
          FROM projects
          WHERE projects.organization_id IN ?
          AND project_members.project_id = projects.id
        )
      SQL

      table.where(
        Sequel.lit(frag, organization)
      )
    end

    scope.with_manager? do  # Project supervisors and the user themselves
      account_id = auth_context.metadata[:id]
      project_ids = auth_context[:project]

      frag = <<-SQL
        (
          project_id IN :project_ids AND is_supervisor = true
        ) OR (
          account_id = :account_id
        )
      SQL

      table.where(
        Sequel.lit(frag, project_ids:, account_id:)
      )
    end
  end
end

Each block is called only when the corresponding scope is present in the token. This structure acts as a switch, and by default if a block is not called, the access is denied.

This example demonstrates several key features:

  1. Multiple Access Patterns: Different user types have different access levels

  2. Custom Scope Data: The scopes use custom data from the auth_context

  3. SQL Fragments: Complex authorization rules are expressed using SQL fragments

  4. Relational Scoping: The by_ou? scope filters through a related table

  5. Clear Intent: The code clearly expresses the authorization rules

Conclusion

The Auth Context pattern has proven to be a robust solution for authentication and authorization in our microservices architecture. By combining JWT tokens with a context object that encapsulates authorization information, we've created a system that is:

  • Secure: Using ECDSA signatures and short-lived tokens

  • Performant: No database queries for permission checks

  • Consistent: Applying authorization rules uniformly across services

  • Flexible: Supporting various access patterns and custom scopes

  • Maintainable: Separating authentication, authorization, and business logic

This approach has allowed us to scale our application while maintaining strong security guarantees and a clean codebase.

Have questions about our authentication system or suggestions for improvements? We'd love to hear from you! Reach out to our team to discuss how these patterns might benefit your architecture.


This article is part of our series on microservice architecture. Stay tuned for more insights into how we've built a scalable, maintainable system.

0
Subscribe to my newsletter

Read articles from Yacine Petitprez directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Yacine Petitprez
Yacine Petitprez