Secure and Scalable Authentication in Microservices

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:
Distributed Security: Each microservice needs to verify user identity and permissions independently.
Performance Concerns: Checking permissions for every request could create bottlenecks.
Consistency: Authorization rules needed to be applied uniformly across services.
Flexibility: Different resources require different access patterns (all users, own data only, team data, etc.).
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 resourceswrite
: Creating or updating resourcesdelete
: Removing resourcesCustom 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 userby_ou
: Access to resources within the user's organizational unitmy_team
: Access to resources for the user's team membersCustom 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:
Auth Token: Short-lived (15 minutes) JWT token used for authentication
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.
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:
User roles: Control access for regular users through the front-end
Service roles: Used for micro-service communication (RPC call). IP-based access control is also implemented.
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:
Consistency Across Access Methods: The same authorization rules apply whether data is accessed via REST API, GraphQL, WebSockets, or internal service calls.
Separation of Concerns: Authorization logic is decoupled from API design, allowing each to evolve independently.
Data-Centric Protection: Security is applied directly to the data layer, preventing authorization bypasses through new or overlooked endpoints.
Reusability: The same authorization rules can be reused across different endpoints that access the same resources.
Alignment with Domain Model: Resource-based security aligns naturally with our domain-driven design approach.
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:
Implementation Complexity: Resource-based security requires more upfront design and consistent implementation across repositories.
Granularity Challenges: Some operations don't map cleanly to CRUD actions on resources and may require custom actions.
Performance Considerations: Applying fine-grained permissions at the data layer can be more resource-intensive than simple endpoint checks.
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:
Multiple Access Patterns: Different user types have different access levels
Custom Scope Data: The scopes use custom data from the auth_context
SQL Fragments: Complex authorization rules are expressed using SQL fragments
Relational Scoping: The
by_ou?
scope filters through a related tableClear 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.
Subscribe to my newsletter
Read articles from Yacine Petitprez directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
