When You Can't Replace What You Haven't Abstracted
A custom authentication and authorization architecture designed to abstract legacy systems, enforce strict session boundaries, and enable incremental migration to a third-party identity provider
The Problem
The platform served approximately 120,000 users across multiple applications, and every one of those applications handled authentication differently. Five separate implementations existed across the legacy Laravel codebase, each with its own database and custom code:
- Primary product API β its own database, custom Laravel auth patterns
- Admin portal β separate database, separate auth implementation
- Marketing application β yet another database, yet another implementation
- Billing integration β third-party API for user access to their billing account
- External scanning tool β different third-party API for access to a partner web application
None of these systems shared sessions, identity stores, or auth contracts. There was no SSO and no unified identity. Migrating users between applications meant rebuilding the auth layer for each one individually.
The organization needed to rebuild most of its software from Laravel to .NET, and the team was small with limited velocity. Deploying the rebuild as large blocks or entire systems was not an option; the team needed to ship single small components in short intervals, which meant legacy and new components had to coexist and communicate throughout the migration.
Why Not Move to Third-Party Immediately?
The team knew a third-party auth solution was the long-term answer. Auth0, Cognito, Firebase, Clerk, and Kinde were all evaluated. Kinde was selected as the eventual target because its data architecture set the right foundation: tenant-based isolation with schema-per-tenant, cross-application SSO within an organization, and environment-level user management that aligned with how the platformβs multi-app ecosystem needed to work. The organization model was mature in ways that mattered for a product with multiple applications sharing a user base.
But migrating 120,000 users to a third-party identity provider while simultaneously rebuilding the entire platform from PHP to .NET was too much risk concentrated in one transition. A big-bang migration to Kinde would have required all applications to switch simultaneously, and there was no realistic timeline where that could happen without halting the broader rebuild.
The decision was to build a custom abstraction layer that unified auth contracts across all products, then migrate to Kinde later without breaking changes to any internal service. Every architectural choice was made knowing where the system was headed.
The Abstraction Strategy
The simplest and most stable approach was to abstract the auth pattern already used by the primary product API, standardize it behind a unified interface, and use that interface for everything new. The critical point is what this did not mean: the team did not migrate every existing application and API to the new abstraction. The legacy Laravel apps and their existing auth implementations continued running exactly as they were.
The abstraction was adopted endpoint by endpoint, feature by feature. When a new capability was needed or an existing one was rebuilt, it used the new auth contracts. Legacy endpoints continued using their existing auth. Both ran side by side within the same applications:
- The new product web app β the primary consumer, built from scratch on .NET
- Background workers β cross-domain and cross-application .NET processors that needed to authenticate across service boundaries
- Ad-hoc admin CLI tools β operational commands that required service-level authentication
- Eventual rebuilds β the admin portal and marketing application would adopt the abstraction when they were themselves rebuilt, not before
Legacy applications were never forced to switch. A single application could have some endpoints using legacy auth and others using the new abstraction, and both worked because all identity providers were supported behind the same interface.
Solution Architecture
The system was designed as two complementary services behind a shared client abstraction that all downstream services consumed.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Client Applications β
β (Web app, Mobile app, Admin portal, Marketing app) β
ββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββ
β
JWT Bearer Token
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β API Services (.NET on ECS) β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β JwtAuthenticationSchemeHandler (ASP.NET middleware) β β
β β Validates token β Looks up session β Builds ClaimsPrincipalβ β
β ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ β
β β β
β Uses IAuthenticationService (shared client library) β
β Uses AuthenticatedHttpClient for service-to-service calls β
ββββββββββββ¬βββββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββ
β β
βΌ βΌ
βββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββ
β Authentication Service β β Authorization Service β
β β β β
β Identity Providers: β β Feature Grants: β
β ββ AppUsers β β ββ Subscription tiers β
β ββ AdminUsers β β ββ Feature flags β
β ββ InternalServices β β ββ Product access β
β ββ ExternalServices β β ββ Grant caching β
β ββ (future: Kinde) β β β
β β β GraphQL API (HotChocolate) β
β Challenge Flows: β β β
β ββ Login β β Sync from billing webhooks β
β ββ MobileLogin β β β
β ββ ServiceLogin β β β
β ββ ThirdPartyLogin β β β
β ββ ProductLogin β β β
ββββββββββββ¬βββββββββββββββ ββββββββββββ¬βββββββββββββββββββββββββββ
β β
read/write read/write
β β
βΌ βΌ
βββββββββββββββββββββββ βββββββββββββββββββββββββββββ
β AWS DynamoDB β β AWS Aurora (MySQL) β
β β β β
β sessions β β Users & Identity β
β sessiontokens β β Feature Tiers β
β β β Subscription Plans β
β (TTL auto-expire) β β β
βββββββββββββββββββββββ β + DynamoDB grant cache β
β + Memcached grant cache β
βββββββββββββββββββββββββββββ
The Authentication Service handled identity verification, session lifecycle management, and JWT token generation. It abstracted multiple identity sources behind an IIdentityProviderRepository interface, with concrete implementations for app users, admin users, internal services, and external services. Each identity source had its own credential verification logic, but all produced the same session contract.
The Authorization Service managed feature grants, subscription tiers, and product-level permissions. It integrated with external subscription providers to sync what users had paid for, then computed and cached grant sets that any API could query. Authentication and authorization were deliberately separated so that changing the identity provider would not affect how permissions worked.
A pluggable Challenge pattern allowed different authentication flows without modifying the core session logic. Each challenge type (password login, mobile login, service-to-service login, third-party service login) implemented the same interface: validate credentials through the appropriate identity provider, create a session, enforce constraints, and return a token. Adding a new authentication method meant implementing a new challenge, not rewriting session management.
Session Model and DynamoDB Design
Sessions and session tokens lived in DynamoDB, chosen specifically for ephemeral data that loses its usefulness after a bounded period.
Table: sessions
ββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββββββββ
β Id (HASH) β Attributes β
ββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββ€
β "session-uuid" β IdentityId, IdentityProviderId, FQRNS[], β
β β Application, Tenant, OriginIp, β
β β ValidTo, CreatedAt, DynamoTTL β
ββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββββββ
Table: sessiontokens
ββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββββββββ
β Token (HASH) β Attributes β
ββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββ€
β "jwt-string" β SessionId, IdentityProviderId, Type, β
β β ValidTo, DynamoTTL β
ββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββββββ
The sessiontokens table used the JWT string itself as the hash key, enabling direct lookup from an incoming token to its parent session without parsing JWT claims first. The sessions table stored the actual session state: the identity, roles (as Fully Qualified Role Names), application, tenant, and origin IP. The origin IP was captured once at the boundary and stored on the session record, enabling security auditing without propagating user sessions through internal services.
Both tables used DynamoDBβs native TTL feature for automatic cleanup. When a session was created, the DynamoTTL field was set to the Unix epoch equivalent of the sessionβs ValidTo timestamp. DynamoDB automatically deleted expired items without cleanup jobs, cron tasks, or manual intervention.
The alternative was storing sessions in the existing RDS databases alongside domain entities. In the legacy system, all data lived in MySQL regardless of its lifecycle, which increased read/write latency for every API action and accumulated storage costs for data like sessions, tokens, and activity logs that was only useful for days or weeks. DynamoDB eliminated both problems: single-digit millisecond reads for hash key lookups, PAY_PER_REQUEST billing that scaled to near-zero during idle periods, and TTL-based expiration that kept the tables lean without operational overhead.
Zero-Trust: Validate Every Request
The system validated every incoming request against the session store rather than trusting JWT claims. This was a deliberate zero-trust design choice: tokens carry references, not authority. The session in the database is the source of truth, and every request confirms it.
The legacy system already worked this way out of necessity. User data changed through multiple disconnected paths: manual database fixes, third-party billing webhooks, admin tools that bypassed the API entirely. There was no unified write path and no way for a cache or JWT claim to know when something changed, so every request validated against the session store. The zero-trust model formalized what the legacy system had stumbled into by accident.
Request arrives with JWT Bearer token
β
βΌ
Extract sessionId from JWT claims
β
βΌ
Query DynamoDB sessiontokens table (Token = JWT string)
β
βΌ
Get SessionId from token record
β
βΌ
Query DynamoDB sessions table (Id = SessionId)
β
βΌ
Validate: session exists AND (ValidTo is null OR ValidTo > now)
β
ββββββ΄βββββ
β VALID β INVALID β Return unauthenticated
ββββββ¬βββββ
β
Build ClaimsPrincipal from session data (FQRNS, IdentityId, Tenant)
β
βΌ
Cache ClaimsPrincipal in-memory (keyed by token, TTL varies by provider)
β
βΌ
Set HttpContext.User = ClaimsPrincipal
β
βΌ
Authorization handlers run ([Authorize] policies check FQRNs)
The JWTβs cryptographic signature was not the primary validation mechanism. The token existed to carry a session reference and to limit the blast radius of token exposure (a leaked JWT was only valid until its expiration, typically one hour). The session in DynamoDB was the source of truth for whether the identity was still valid and what roles it held. If a session was invalidated (user terminated, access revoked, max sessions exceeded), the very next API call would fail regardless of the JWTβs validity.
User sessions were never cached. Every user request hit DynamoDB to confirm the session was still valid. This was the zero-trust model in practice: if a session was invalidated between requests, the very next call would fail. No stale cache could grant access after revocation.
Service sessions were different. An in-memory ConcurrentMemoryCache<ClaimsPrincipal> cached resolved service sessions because service identity changed far less frequently and the volume of service-to-service calls would have otherwise created unnecessary DynamoDB load.
The total latency cost for user requests was 5-10ms per uncached lookup against a properly partitioned DynamoDB table. For API requests that typically took 50-200ms, this was imperceptible. The tradeoff bought immediate revocation, predictable behavior across all the disconnected write paths, and a simpler mental model where authorization lived in one place. For a deeper analysis of why JWTs make poor authorization tokens, see Why JWTs Make Terrible Authorization Tokens.
Customer Sessions vs. Service Sessions
The system supported two distinct session types through the same framework, distinguished by their identity provider rather than by separate code paths.
User Sessions
User sessions authenticated customers and employees through username/password credentials verified with BCrypt against MySQL user records. Each session carried the userβs Fully Qualified Role Names (FQRNs), application context, tenant, and origin IP. Sessions expired after 30 days, and a maximum of 4 concurrent sessions per user per application prevented unlimited parallel session accumulation.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β User Session β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ€
β Identity β UserId, IdentityProviderId β
β Authorization β FQRNs (eligible roles) β
β Context β Application, Tenant, OriginIp β
β Lifecycle β ValidTo (30 days), CreatedAt β
ββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββ
When a user logged in, the system checked their FQRNs against the applicationβs eligibility requirements. A user with platform.apps:user could authenticate to the flagship app, but not to the admin portal which required platform.apps:admin. This prevented users from accessing applications they were not authorized for, even if they had valid credentials.
Service Sessions
Service-to-service sessions authenticated internal APIs through secret keys stored in AWS Parameter Store. Unlike user sessions, service sessions had no expiration and were limited to one per service. Services reused existing sessions across calls and only created new ones when no valid session existed or when the current session was approaching a configurable refresh threshold.
This reuse pattern minimized DynamoDB writes for services that called each other frequently. A service making hundreds of calls per minute used the same session token for all of them rather than creating a new session per request. A ManualResetEvent lock with a 4-second timeout prevented thundering-herd problems when multiple threads simultaneously needed a new token.
Why Sessions Never Cross Boundaries
User sessions were validated at the API boundary and consumed there. Once validated, the session was replaced with explicit context (user_id, tenant_id, correlation_id) and service credentials. Internal services authenticated as themselves with their own service sessions. They never received, forwarded, or validated user session tokens.
This separation meant that compromising a user token did not automatically compromise service-to-service communication. Each service controlled its own authorization model without needing to understand user permission structures. Services could be tested, deployed, and scaled independently because they did not depend on user token formats, refresh logic, or identity provider availability.
The same architecture worked identically whether a request originated from a user login, a webhook, a scheduled job, or a system maintenance task. There was no special case for βrequests without user contextβ because internal services never expected user context in the first place. For a deeper analysis of why authentication sessions should not propagate through internal systems, see Auth Sessions Should Never Be Transient Across Boundaries.
The Client Abstraction
Every downstream service consumed authentication through a single interface: IAuthenticationService. This was the contract that made the entire architecture work.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β IAuthenticationService β
β (shared client library) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Validate token β resolve session β ClaimsPrincipal β
β Get or refresh service session token β
β Retrieve authorization grants β
β Inject Bearer header on outgoing HTTP requests β
β Check local session state β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
For service-to-service calls, an AuthenticatedHttpClient wrapped a standard HTTP client and automatically injected the serviceβs Bearer token on every outgoing request. A service that needed to call another service did not manage tokens, refresh logic, or authentication headers. It constructed an AuthenticatedHttpClient at startup and made HTTP calls.
ββββββββββββββββ ββββββββββββββββββββββββ ββββββββββββββββββββ
β Service A ββββββββΆβ AuthenticatedHttp ββββββββΆβ Service B β
β β β Client β β β
β Knows: β β Handles: β β Validates: β
β - Endpoint β β - Token injection β β - Bearer token β
β - Payload β β - Session refresh β β - Session lookupβ
β β β - Auth headers β β - FQRN policies β
ββββββββββββββββ ββββββββββββββββββββββββ ββββββββββββββββββββ
On the receiving side, JwtAuthenticationSchemeHandler integrated with ASP.NETβs authentication pipeline. It extracted the Bearer token from the request, called GetClaimsPrincipal to validate the session, and set the HttpContext.User to the resulting ClaimsPrincipal. Standard [Authorize] attributes and custom policy handlers then checked FQRNs for endpoint-level access control.
Onboarding a new service required three steps: register IAuthenticationService as a singleton, use AuthenticatedHttpClient for outgoing calls, and add [Authorize] attributes to endpoints. The service did not need to know how tokens were validated, where sessions were stored, or which identity provider issued the token. That isolation was the entire point.
Feature Grants and Authorization
Authentication determined who you were. Authorization determined what you could do. These two concerns were deliberately separated so that changing the identity provider would not affect how product access worked.
Roles were immutable on the session. A userβs FQRN (like platform.apps:user or platform.servicing:admin) was set when the session was created and did not change until the session expired or was replaced. For most API endpoints, the role was the only authorization check needed. Serious access changes (termination, role elevation) resulted in session invalidation rather than mid-session role modification.
Product access was different. Feature grants were derived from subscription tiers and computed dynamically, never embedded in the JWT or stored on the session. When an API endpoint needed to check whether a user had access to a specific feature, it queried the authorization service which returned the userβs current grants:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β UserGrants β
βββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ€
β Feature IDs β Individual feature access β
β β (e.g., analytics, reports) β
βββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββ€
β Grant Group IDs β Subscription tier membership β
β β (e.g., freemium, pro) β
βββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββ€
β Products β Product-level feature groups β
β β (e.g., content β blog, video) β
βββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββ
This was fetched when needed, not assumed to be part of every request. APIs that did not need grant information did not pay the cost of retrieving it.
Grant Caching and Synchronization
Computing grants required joining subscription data with feature tier definitions, so the result was cached in two tiers. DynamoDB stored computed grants with a 15-minute TTL for cross-instance persistence. Memcached provided faster lookups within a single instanceβs lifetime.
When a userβs subscription changed (a Recurly webhook fired, an admin manually adjusted a plan, or a trial expired), the sync process updated the database, cleared both caches, and the next request recalculated grants from the updated data. Users saw subscription changes reflected immediately on their next API call without logging out and back in. This was one of the concrete benefits of not embedding grants in JWTs: a subscription upgrade took effect within one request cycle, not after token expiration.
Designing for the Kinde Migration
Every architectural decision described above was made knowing that Kinde was the eventual identity provider. The question was never whether to migrate, but how to make the migration as small and safe as possible when the time came.
The Lazy Internal Session Pattern
The integration design chosen was what the team called βLazy Internal Session.β When the migration began, the API would receive tokens from Kinde instead of (or alongside) the custom auth system. The API would detect that the incoming token was not an internal session, call the authentication service to get-or-create an internal session mapped to the Kinde token, and proceed exactly as before. Internal services would never know the difference.
Client authenticates with Kinde β Receives Kinde JWT
β
βΌ
API receives Kinde JWT in Authorization header
β
βΌ
JwtAuthenticationSchemeHandler detects non-internal token
β
βΌ
Calls Authentication Service: GetOrCreateSessionForExternalToken()
β
βΌ
Authentication Service:
1. Validates Kinde token
2. Maps Kinde identity to platform user
3. Creates internal session with mapped FQRNs
4. Returns internal session token
β
βΌ
API proceeds with internal session (identical to current flow)
β
βΌ
Internal services receive platform sessions, platform roles, platform grants
(zero changes required)
This pattern allowed the UI and integration points to be migrated in intervals rather than all at once. One application could switch to Kinde while others continued using the custom auth. Service-to-service authentication did not change at all since it was never user-oriented and had its own session management with internal roles that were unrelated to user identity.
User Migration Strategy
User migration would use Kindeβs import capability with bcrypt password hashes, meaning existing users could log in with their current passwords without a forced reset. For the transition period, existing sessions would be allowed to expire naturally (30 days maximum), and the Lazy Internal Session pattern would handle the first post-migration login seamlessly. Employee migrations could be handled ad-hoc without building dedicated tooling.
What Iβd Reconsider
Looking back, the team should have at least considered mTLS for service-to-service authentication instead of service role tokens.
Service actor identity proved quite useful in practice. Knowing that βService A called this endpointβ (not just βa valid service token was usedβ) enabled meaningful audit trails, per-service rate limiting, and fine-grained access control over which services could call which endpoints. That granularity would have been harder to achieve with mTLS alone, where identity is typically at the certificate level rather than the request level.
That said, mTLS would have provided stronger transport-level authentication guarantees. With service role tokens, the security model depends on secret management: if a serviceβs auth secret is compromised, an attacker can impersonate that service until the secret is rotated. With mTLS, certificate compromise requires access to the private key, and certificate rotation is handled by infrastructure rather than application code. The tradeoff is operational complexity since certificate management, rotation, and distribution across a container fleet adds infrastructure overhead that the team was not positioned to absorb at the time.
The hybrid approach, mTLS for transport-level authentication plus service identity tokens for application-level authorization, would have been the strongest option. Whether the additional infrastructure complexity would have justified the security improvement depends on the threat model, and at the time, secret management in Parameter Store was sufficient for the organizationβs risk tolerance.
Tradeoffs and Limitations
No complex session analytics. DynamoDBβs hash-key access pattern meant the system could efficiently look up a specific session or all sessions for a specific identity, but it could not answer questions like βshow me all sessions created in the last hour by admin usersβ without a full table scan. An async write to MySQL could have provided queryable session analytics, but there was no business need for it at the time.
Medium-term architecture by design. The custom authentication system was not intended to be the permanent solution. It was the cost of flexibility: if Kinde didnβt work out, the team could swap to a different provider without touching internal services.
Find this case study insightful? Share it with your network:
Share on LinkedIn