Skip to content

CORS middleware duplicates headers when used in chained proxy servers #2853

Closed

Description

@rLukoyanov

Description

When building a proxy server using Echo (golang) and enabling the built-in middleware.CORS() on each proxy layer, CORS headers are duplicated in the final response.

This happens in a common microservice / gateway setup where:

  • multiple Echo-based services act as proxies,
  • each service enables CORS middleware,
  • requests pass through several proxy layers before reaching the client.

As a result, the response contains duplicated CORS headers such as:

Access-Control-Allow-Origin: *
Access-Control-Allow-Origin: *
Vary: Origin
Vary: Origin
Image

This behavior breaks some browsers and violates expected HTTP header semantics.


Steps to Reproduce

  1. Create Service A (proxy) with Echo:

    func main() {
     e := echo.New()
    
     e.Use(middleware.Recover())
     e.Use(middleware.CORS())
    
     serviceAURL, err := url.Parse("http://localhost:8080")
     if err != nil {
     	e.Logger.Fatal(err)
     }
     serviceAProxy := httputil.NewSingleHostReverseProxy(serviceAURL)
    
     e.Any("/service-a/*", func(c echo.Context) error {
     	req := c.Request()
     	res := c.Response()
    
     	req.URL.Path = strings.TrimPrefix(req.URL.Path, "/service-a")
     	if req.URL.Path == "" {
     		req.URL.Path = "/"
     	}
    
     	serviceAProxy.ServeHTTP(res, req)
     	return nil
     })
    
     e.Logger.Fatal(e.Start(":8081"))
    }
  2. Create Service B (proxy) with the same setup:

    func main() {
     e := echo.New()
    
     e.Use(middleware.Recover())
     e.Use(middleware.CORS())
    
     e.GET("/", func(c echo.Context) error {
     	return c.JSON(http.StatusOK, map[string]string{
     		"service": "service-a",
     		"version": "1.0.0",
     		"status":  "running",
     	})
     })
    
     e.GET("/health", func(c echo.Context) error {
     	return c.JSON(http.StatusOK, map[string]string{
     		"status": "healthy",
     	})
     })
    
     e.Logger.Fatal(e.Start(":8080"))
    }
  3. Route traffic:

    Client → Service A → Service B
    
  4. Send a request from the browser or postman.


Expected Behavior

CORS headers should not be duplicated in the final response.
Ideally:

  • Echo should override existing CORS headers, or
  • CORS middleware should check if headers are already present before adding them.

Example:

Access-Control-Allow-Origin: *

Actual Behavior

Each proxy layer appends its own CORS headers, resulting in duplicates.


Why this is a problem

This is a realistic production scenario:

  • API Gateway
  • Internal proxy services
  • BFF layers

Disabling CORS in intermediate services is not always possible or desirable, especially when services are also used independently.


Environment

  • Echo version: v4.14.0
  • Go version: go1.24.0
  • OS: macOS

Activity

  1. added a commit that references this issue on Feb 19, 2026
    d3283c8
  2. added a commit that references this issue on Mar 12, 2026
    a8a532d
  3. vishr commented on Jun 24, 2026

    @vishr
    Member

    Thanks for the detailed report.

    This is expected behavior rather than a bug in the CORS middleware. The duplication comes from running CORS on more than one layer of the chain: the gateway's CORS middleware sets the headers, and then the reverse proxy copies the upstream's CORS headers on top of them. The middleware can't safely undo that without wrapping the ResponseWriter in security-sensitive code to compensate for a configuration choice — which we'd rather not bake into the default. rs/cors reached the same conclusion for the identical request (rs/cors#45).

    The fix belongs at the boundary:

    • Own CORS at the edge only — internal upstream services behind a gateway generally shouldn't run CORS; let the outermost service own it.
    • Strip the upstream's CORS headers when proxying, if an upstream must keep CORS for standalone use, via httputil.ReverseProxy.ModifyResponse (delete every Access-Control-* header, and remove only the Origin token from Vary so unrelated caching directives survive).

    Documented here:

    Closing as working-as-intended once those land — happy to reopen if there's a case this guidance doesn't cover.

  4. self-assigned this
    on Jun 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions