Centralising Web Authentication & CSRF on Tyk Gateway at Halodoc
Introduction
At Halodoc, we continuously evaluate our architecture to ensure optimal performance, scalability, and maintainability. As our platform evolved, we identified opportunities to consolidate our API management infrastructure and reduce operational complexity.
In our previous migration from AWS API Gateway to Tyk, we successfully transitioned our mobile application traffic to Tyk Gateway. This move brought significant benefits including better performance, cost optimisation, and unified API management. Building on that success, we decided to extend Tyk's capabilities to our web platform by migrating authentication and CSRF handling from our Proxy layer to the gateway.
Why Migrate to Tyk?
Our web architecture faced several challenges that made migration necessary:
- Multi-hop latency: Each API request traveled through Proxy → Redis (session lookup) → Authentication Service (validation) → Backend Services, adding network overhead at every step
- Inefficient session management: Redis clusters managed session storage despite authentication tokens having fixed expiry periods requiring re-login, unlike systems with refresh tokens that genuinely benefit from persistent sessions
- Maintenance complexity: Authentication logic duplicated across multiple Proxy implementations made it difficult to maintain consistency and deploy security updates uniformly
Why Tyk Gateway
Tyk aligned perfectly with our requirements for several key reasons:
- Lightweight & high-performance: Built in Go, handles high throughput with minimal resource consumption
- Custom plugin architecture: Implement business logic in Go for web-specific requirements like cookie authentication and CSRF protection
- Unified API management: Consolidate mobile and web traffic through single platform with centralized policies
- Flexible configuration: Manage authentication policies, rate limiting, and security rules consistently across all API traffic
Understanding the Architecture
Previous Architecture
Our web applications communicated with backend services through dedicated Proxy layers , using session-based authentication with Redis as the session store.
The authentication flow involved multiple hops: After successful user authentication, the Web Proxy managed session creation and storage. For subsequent requests, the browser sent session identifiers, and the proxy performed session lookup and validation before forwarding requests to backend services with appropriate user context.
This multi-hop architecture introduced measurable latency at each step, with session lookups and validation calls adding overhead to every request. With approximately 16,000 requests per minute, this resulted in tens of thousands of network calls, contributing to high CPU utilisation on Proxy pods.
Target Architecture with Tyk
The new architecture introduces Tyk Gateway as the entry point for all web API traffic, fundamentally changing how authentication and routing work.
For logged-out users, the flow is straightforward: Browser → Tyk Gateway → Backend Services. For authenticated users, we now use token-based authentication. Tyk Gateway handles authentication validation and injects verified user context into requests before forwarding to backend services.
This architecture eliminates the session storage layer entirely and centralizes authentication logic at the gateway level. Backend services receive pre-validated user context, simplifying their authentication handling and reducing the number of network hops in the request flow.
Migration Process
Step 1: Evaluation
Before committing to a full migration, we validated that Tyk could handle web-specific authentication requirements. We selected a subset of low-traffic APIs representing different patterns and developed custom Go plugins for cookie-based authentication and CSRF validation using the double-submit cookie pattern.
We built a test environment in staging and measured response times, authentication success rates across browsers, and CSRF validation metrics. The POC revealed acceptable latency overhead that would be offset by architectural benefits, and confirmed our plugins worked reliably with Auth Service integration.
Step 2: Integration Layer Setup
With POC validated, we integrated Tyk as an intermediary between web applications and existing Proxy infrastructure. The critical requirement was zero downtime, which meant supporting both old and new authentication flows simultaneously during migration.
Web Application Changes: Rather than updating every endpoint individually, we changed the base URL configuration. Previously, frontend applications called <proxy-base-url>/api/v1/users, which we changed to <tyk-base-url>/proxy-name/v1/users. This single configuration change allowed all API calls to route through Tyk while maintaining the same endpoint paths.
Tyk Gateway Configuration: We created comprehensive API definitions specifying listen paths, target URLs, and custom middleware plugin sequences. The plugin execution order was critical: pre-request plugins handled CORS validation, app token verification, user agent detection, and CSRF checking. Authentication plugin validated user tokens if required. Response plugins handled CSRF token generation for new sessions.
Here's a simplified Tyk API definition showing key configuration:
Example Tyk API Definition (simplified):
{
"name": "proxy-a_v1",
"api_id": "proxy-a_v1",
"use_go_plugin_auth": true,
"proxy": {
"listen_path": "/proxy-name/v1/",
"target_url": "http://proxy-name-service:8080/api/v1/",
"strip_listen_path": true
},
"custom_middleware": {
"pre": [
{"name": "CORSPlugin", "path": "/plugins/cors_plugin.so"},
{"name": "AppTokenCheck", "path": "/plugins/app_token_plugin.so"},
{"name": "CSRFCheck", "path": "/plugins/csrf_plugin.so"}
],
"auth_check": {
"name": "CookieAuthCheck",
"path": "/plugins/cookie_auth_required.so"
},
"response": [
{"name": "CSRFResponseMirror", "path": "/plugins/csrf_plugin.so"}
]
},
"config_data": {
"authenticated_paths": [
{"path": "/appointments", "method": "POST"},
{"path": "/profile/*", "method": "GET"}
]
}
}The authenticated_paths section tells our authentication plugin which routes require user authentication, allowing us to support both logged-in and logged-out users appropriately.
Proxy Layer Changes: Proxy required modifications to support dual authentication modes during the transition period. We implemented logic that could identify whether requests came through Tyk Gateway (with pre-validated authentication) or directly from legacy clients. For pre-validated requests, the Proxy forwarded them directly to backend services. For legacy requests, the Proxy maintained the original authentication flow.
This dual-mode approach was essential for zero-downtime migration. We could deploy updated Proxy code before migrating traffic to Tyk. Old web clients continued using the existing flow, while new traffic through Tyk used the optimised flow. Both flows coexisted in the same Proxy instances without interference.
CDN Configuration: we created a new cache behavior for /proxy-name/* path pattern, configured to forward all headers and cookies to origin. This ensured Tyk could see authentication cookies and CSRF headers for proper validation. We also ensured Set-Cookie headers in responses propagated correctly back to browsers for CSRF and session token cookie setting.
Step 3: Custom Plugin Development
Tyk's extensibility through Go plugins was central to our migration success. While Tyk provides many built-in features, our specific requirements around cookie-based authentication, CSRF protection, and request validation needed custom implementations.
Cookie-Based Authentication Plugin
The authentication plugin is the heart of our security model. Unlike Tyk's built-in authentication methods which support API keys and bearer tokens, we needed cookie-based authentication handling browser-specific nuances.
The plugin determines whether the current request requires authentication by checking configuration mappings in the Tyk API definition. Public endpoints like health checks bypass authentication entirely, while sensitive operations require validated tokens. For authenticated routes, the plugin extracts authentication credentials from the request and validates them with our authentication service.
Once validated, the plugin injects verified user context into request headers that upstream services can trust. The plugin handles error scenarios gracefully: network failures, invalid credentials, and expired tokens result in appropriate HTTP 401 unauthorised responses with clear error messages.
High-level implementation:
func CookieAuthCheck(rw http.ResponseWriter, r *http.Request) {
// Check if current path requires authentication
apiDef := r.Context().Value(/* API definition */)
requiresAuth := checkAuthRequired(r.URL.Path, r.Method, apiDef.ConfigData)
if !requiresAuth {
return // Allow anonymous access
}
// Extract and validate credentials
credentials := extractAuthCredentials(r)
if credentials == "" {
respondUnauthorized(rw, "Missing authentication credentials")
return
}
// Validate with authentication service and inject user context
userInfo, err := validateWithAuthService(credentials)
if err != nil {
respondUnauthorized(rw, "Invalid or expired credentials")
return
}
injectUserContext(r, userInfo)
}The plugin handles error scenarios gracefully: network failures, malformed tokens, expired tokens, and missing cookies result in HTTP 401 Unauthorised responses with clear error messages.
CSRF Validation Plugin
Our CSRF implementation uses the double-submit cookie pattern: generate a random token, send it to the browser through multiple channels, then verify these values match on state-changing requests. This pattern is both secure and stateless.
We implemented CSRF as two plugins executing at different request lifecycle points. CSRFCheck runs during pre-request phase before authentication, while CSRFResponseMirror runs during response phase after backend processing.
The request-phase plugin first checks if CSRF validation is needed. We skip validation for safe HTTP methods (GET, HEAD, OPTIONS) and maintain a configuration list of exempt paths. For requests requiring validation, the plugin validates the request origin, preventing attackers from creating malicious websites making cross-origin requests. Then it verifies the CSRF tokens. If validation fails, the request is rejected with 403 Forbidden.
func CSRFCheck(rw http.ResponseWriter, r *http.Request) {
// Skip safe methods and exempt paths
if isSafeMethod(r.Method) || isExemptPath(r.URL.Path) {
return
}
// Validate same-origin policy
if !isAllowedOrigin(r) {
respondForbidden(rw, "Request origin not allowed")
return
}
// Validate CSRF tokens
if !areCSRFTokensValid(r) {
regenerateCSRFToken(rw)
respondForbidden(rw, "CSRF validation failed")
return
}
}The response-phase plugin handles token generation and distribution after backend processing completes. This ensures the frontend can include the token in subsequent requests while the browser automatically manages token persistence.
CORS Handler Plugin
Cross-Origin Resource Sharing is essential because our frontend and API domains differ. The CORS plugin manages HTTP header exchanges required for secure cross-origin requests while preventing unauthorised access.
The plugin distinguishes between preflight OPTIONS requests and actual API requests. Browsers send OPTIONS requests before making cross-origin requests with side effects, asking "is this allowed?". Our plugin handles preflight requests directly, responding immediately without forwarding to backend services.
func CORSPlugin(rw http.ResponseWriter, r *http.Request) {
// Get path-specific CORS configuration
corsConfig := getCORSConfigForPath(r.URL.Path, apiDef.ConfigData)
if !corsConfig.Enabled {
return
}
origin := r.Header.Get("Origin")
// Handle preflight OPTIONS requests
if r.Method == "OPTIONS" {
if !isOriginAllowed(origin, corsConfig.AllowedOrigins) {
rw.WriteHeader(http.StatusForbidden)
return
}
setCORSHeaders(rw, origin, corsConfig)
rw.WriteHeader(http.StatusOK)
return
}
// Add CORS headers for actual requests
if isOriginAllowed(origin, corsConfig.AllowedOrigins) {
setCORSHeaders(rw, origin, corsConfig)
}
}CORS configuration is managed per API definition, allowing different policies for different services.
User Agent Plugin
Backend services need to identify whether requests originate from web browsers or mobile apps for platform-specific logic. The plugin detects web requests through browser-specific request attributes and injects a standardised identifier that backend services recognise.
func UserAgentCheck(rw http.ResponseWriter, r *http.Request) {
// Detect request source
isWebRequest := detectWebRequest(r)
if isWebRequest {
r.Header.Set("User-Agent", " {{WHITELISTED_USER_AGENT}}")
}
}Note: This required updating the web app's Referer-Policy from no-referrer to strict-origin-when-cross-origin.
Step 4: Gradual Traffic Migration
With all technical components in place, we began carefully migrating production traffic. The migration strategy was built around gradual rollout with continuous monitoring.
We started with comprehensive staging validation replicating production setup exactly. We ran automated test suites covering login flows, authenticated and unauthenticated API calls, CSRF token handling, session timeout scenarios, and error cases. Load testing validated the system could handle production-scale traffic. Staging validation lasted several days while we monitored error rates, response times, and authentication success rates.
For production deployment, we followed a carefully orchestrated sequence:
- Deploy Tyk Gateway with all custom plugins without routing production traffic
- Deploy updated Proxy with dual-mode authentication support (backward compatible)
- Configure CDN with new behavior to support cookie and headers required for web use case.
- Deploy Web Application updates with new endpoint URLs using feature flags
The traffic shift phase used weight based web application deployment:
- Phase 1: Route 25% through Tyk, monitor for few hours.
- Phase 2: Increase to 100% . to route all traffic via Tyk.
during phase 1, we monitored authentication success rates, API error rates, response time percentiles (P50, P90, P99), Authentication Service call patterns, and CSRF validation metrics. If metrics remained stable, we increased the percentage. The gradual approach meant problems would affect only a subset of users, and we could quickly roll back by updating CDN configuration.
Challenges Faced and Solutions
Maintaining Local Development Simplicity
Tyk Gateway introduced complexity with Docker, Go plugin compilation, and configuration management that would slow developer velocity. We implemented dual-mode support in the proxy: developers can continue using the existing local flow without Tyk, while those working on gateway features use Docker Compose setup. Staging environment provides full integration testing before production.
Zero Downtime Deployment
Coordinating frontend, Tyk, and proxy deployments without downtime required backward compatibility. We built the proxy to support both old and new authentication flows simultaneously, deployed in stages with thorough staging validation, and used CDN routing rules to gradually shift traffic with real-time monitoring. Rollback capability via configuration changes remained available throughout..
Cookie Storage Migration
Changing from session IDs to access tokens in cookies required careful coordination. We updated the authentication flow to use improved token format with security attributes, and the proxy's dual-mode support handled both old and new formats during migration. After stabilization, we removed backward-compatibility code.
Benefits Achieved
The migration to Tyk Gateway delivered measurable improvements across multiple dimensions of our infrastructure.
Performance improvements were immediately visible. Average API response time improved approximately 17% improvement.We eliminated lookup latency from the critical authentication path.
Resource optimization resulted in concrete infrastructure savings. Proxy pod count reduced by approximately 32%, while CPU utilisation dropped from approximately 70% to 40%. Memory utilisation similarly decreased from approximately 70% to 45%. The Proxy layer now handles approximately 65% less requests per minute. Tyk Gateway runs efficiently with already stable setup configured for mobile Apps use case with slight increase in CPU.
Cost savings came from reduced proxy pod count, saving approximately $14 monthly. We also optimized Redis memory allocation, providing additional cost benefits.
Architectural benefits extended beyond immediate metrics. We simplified our architecture by eliminating session storage dependency and reducing network hops in authentication flows. We centralised API management across mobile and web platforms. The plugin-based extensibility gives us flexibility for future enhancements, while unified monitoring simplifies operations.
Security improvements include centralised CSRF protection at gateway level, standardised CORS handling, and token-based authentication without persistent session storage.
Conclusion
Migrating web authentication and CSRF handling to Tyk Gateway was a transformative step in simplifying Halodoc's infrastructure. By centralizing authentication, eliminating Redis session storage, and building on our successful AWS-to-Tyk migration experience, we achieved significant improvements in performance, resource utilisation, and operational simplicity.
The migration process, while challenging, was executed successfully through careful planning, backward compatibility, and gradual traffic shifting. The custom plugin architecture provided the flexibility we needed to implement web-specific authentication while maintaining Tyk's performance benefits. Most importantly, we achieved zero downtime during the entire migration, demonstrating that major architectural changes can be made safely with the right approach.
This migration positioned us for future optimisations, including passthrough API routing and further Proxy load reduction, which will continue to improve our platform's efficiency and scalability. The unified API gateway across mobile and web platforms simplifies our operations and provides a solid foundation for future growth.
About Halodoc
Halodoc is the number one all-around healthcare application in Indonesia. Our mission is to simplify and deliver quality healthcare across Indonesia, from Sabang to Merauke.
Since 2016, Halodoc has been improving health literacy in Indonesia by providing user-friendly healthcare communication, education, and information (KIE). In parallel, our ecosystem has expanded to offer a range of services that facilitate convenient access to healthcare, starting with Homecare by Halodoc as a preventive care feature that allows users to conduct health tests privately and securely from the comfort of their homes; My Insurance, which will enable users to access the benefits of cashless outpatient services more seamlessly; Chat with Doctor, which allows users to consult with over 20,000 licensed physicians via chat, video or voice call; and Health Store features that allow users to purchase medicines, supplements and various health products from our network of over 4,900 trusted partner pharmacies. To deliver holistic health solutions in a fully digital way, Halodoc offers Digital Clinic services, including Haloskin, a trusted dermatology care platform guided by experienced dermatologists.
We are proud to be trusted by global and regional investors, including the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. With over USD 100 million raised to date, including our recent Series D, our team is committed to building the best personalised healthcare solutions, and we remain steadfast in our journey to simplify healthcare for all Indonesians.