CORS #

Cross-Origin Resource Sharing (CORS) is a web-standard-based security protocol that defines how web applications running on one domain (origin) can safely access resources on other domains. In the modern web architecture era where frontend repositories (like React, Svelte, Vue, or Angular) are deployed on separate domains or subdomains from the backend API, understanding and correctly configuring CORS becomes crucial. Failure to configure CORS not only blocks communication between frontend and backend, but overly loose configuration mistakes can also open security holes endangering your user data. We’ll thoroughly examine the fundamentals of the Same-Origin Policy (SOP), the anatomy of preflight requests, how to efficiently implement CORS headers in Caddy, credential handling techniques, dynamic domain whitelist management, comparing CORS at the proxy level versus the backend level, and practical strategies for diagnosing and solving the CORS error messages that often confuse developers.


CORS Availability and Approaches in Caddy #

Caddy is designed with a minimalist and modular philosophy. Therefore, Caddy doesn’t have a built-in directive named cors in its standard configuration. This design decision was made because CORS functionality can essentially be fully implemented using HTTP header manipulation and request matching.

In Caddy, you handle CORS using two main built-in features:

  1. The header Directive: Used to add, modify, or remove the HTTP response headers required by the CORS specification.
  2. Matchers: Used to detect specific request methods (like OPTIONS for preflight requests) or to filter the Origin header values sent by client browsers.

This approach gives you full control over how the server responds to cross-domain requests without relying on additional modules. You don’t need to install third-party plugins using xcaddy just to enable basic to advanced CORS. All CORS handling logic can be written directly using a very clean, easy-to-maintain standard Caddyfile.


Understanding the Same-Origin Policy Concept and Why CORS Is Needed #

Before configuring CORS in Caddy, you must understand why this mechanism exists in browsers. The main foundation of web security is the Same-Origin Policy (SOP). SOP is a strict security rule applied by all modern browsers to prevent scripts (like JavaScript run through fetch or XMLHttpRequest) on one web page from reading sensitive data from another web page at a different origin.

What Is an Origin? #

In the web security context, an Origin is defined as the combination of three components:

  1. Scheme (Protocol): For example http or https.
  2. Host (Domain): For example example.com or api.example.com.
  3. Port: For example 80, 443, or 8080.

Two URLs are said to have Same Origin only if all three components are identical. If any component differs, the browser considers them Cross Origin.

Let’s look at the origin comparison illustration table below with the main reference target: https://api.example.com:443/v1/users

Requesting Client URLDifferent ComponentsRelationship CategoryDefault SOP Access Status
https://api.example.com/v1/profilesNone (HTTPS default port is 443)Same OriginAllowed (Full Access)
http://api.example.com/v1/usersScheme (http vs https)Cross OriginBlocked by SOP
https://api.example.com:8443/v1/usersPort (8443 vs 443)Cross OriginBlocked by SOP
https://www.example.com/v1/usersHost (www.example.com vs api.example.com)Cross OriginBlocked by SOP
https://example.com/v1/usersHost (example.com vs api.example.com)Cross OriginBlocked by SOP

Why Is SOP So Strict? #

Imagine if SOP didn’t exist. If you’re logged into your banking account at https://bank.example.com, the browser stores your session cookie. Then, you accidentally open a malicious site https://evil.example.com in another tab. Without SOP, JavaScript running on https://evil.example.com could easily send a fetch command to https://bank.example.com/transfer to send your money to the attacker’s account. The browser automatically includes your bank session cookie because the request is directed to the bank domain.

SOP prevents scripts from https://evil.example.com from reading your bank’s responses. However, in modern application scenarios, you often deliberately separate the frontend domain (https://dashboard.example.com) and the backend API domain (https://api.example.com). This is where CORS comes in as the official safe mechanism for backend servers to tell the browser: “I trust this frontend domain, please allow JavaScript from that domain to read my responses.”


The CORS Handshake Mechanism #

When a frontend application tries to make a cross-domain request, the browser splits that request into two categories based on risk level: Simple Requests and Non-Simple Requests (requests requiring preflight).

1. Simple Requests #

The browser categorizes a request as a Simple Request if it cumulatively meets the following criteria:

  • Uses the HTTP methods: GET, POST, or HEAD.
  • Only uses safe built-in headers like Accept, Accept-Language, Content-Language, and Content-Type.
  • The allowed Content-Type header values are limited to: application/x-www-form-urlencoded, multipart/form-data, or text/plain.

For Simple Requests, the browser directly sends the actual request to the server. However, the browser includes an additional header named Origin (e.g., Origin: https://dashboard.example.com). The server processes the request, and when returning the response, the server must include an Access-Control-Allow-Origin header containing that client domain. If the browser sees that response header doesn’t match the origin, the browser hides the response from your frontend JavaScript and triggers an error.

2. Requests with Preflight (Non-Simple Requests) #

If your request uses custom methods (like PUT, DELETE, PATCH), sends data types like application/json, or includes custom headers (e.g., the Authorization header for JWT token authentication), the browser considers the request to have higher security risk.

To prevent dangerous requests from damaging server data before authorization, the browser performs an OPTIONS Preflight Request mechanism. The browser first sends an initial request with the OPTIONS method to the server to verify whether the server allows that actual request.

Let’s look at the preflight communication flow through the following sequence diagram:

sequenceDiagram
    participant Browser as "Client Browser"
    participant Caddy as "Caddy Web Server"
    participant Backend as "Backend Application"

    Note over Browser: 1. Detects a non-simple request (e.g. Content-Type: application/json)
    Browser->>Caddy: OPTIONS /api/resource HTTP/1.1<br/>Origin: https://dashboard.example.com<br/>Access-Control-Request-Method: PUT<br/>Access-Control-Request-Headers: Authorization, Content-Type
    
    Note over Caddy: 2. Caddy processes OPTIONS directly<br/>Checks the domain whitelist & composes CORS headers
    Caddy-->>Browser: HTTP/1.1 204 No Content<br/>Access-Control-Allow-Origin: https://dashboard.example.com<br/>Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS<br/>Access-Control-Allow-Headers: Authorization, Content-Type<br/>Access-Control-Max-Age: 86400<br/>Access-Control-Allow-Credentials: true
    
    Note over Browser: 3. Preflight verification succeeds in the browser<br/>Sends the actual request
    Browser->>Caddy: PUT /api/resource HTTP/1.1<br/>Origin: https://dashboard.example.com<br/>Authorization: Bearer token_...ype: application/json
    
    Caddy->>Backend: Forward the actual PUT Request
    Backend-->>Caddy: Data Response from the Application (HTTP 200 OK)
    
    Caddy-->>Browser: HTTP/1.1 200 OK<br/>Access-Control-Allow-Origin: https://dashboard.example.com<br/>Access-Control-Allow-Credentials: true

In the handshake above:

  • Origin: States the requesting frontend application’s origin.
  • Access-Control-Request-Method: Tells the server what method will be used on the actual request.
  • Access-Control-Request-Headers: Tells the server what custom headers will be sent on the actual request.
  • Access-Control-Max-Age: Determines how long browsers are allowed to cache this preflight verification result (in seconds). During this cache period, the browser won’t send another OPTIONS request for the same endpoint, saving bandwidth and improving your application’s performance.

Basic CORS Configuration in Caddy #

Now we’ll write the basic Caddyfile configuration to handle CORS. We assume your frontend application runs at https://dashboard.example.com and wants to access the API at https://api.example.com.

Basic Caddyfile Writing #

# Example: Basic CORS Configuration for a Single Origin
api.example.com {
    # 1. Define a matcher to detect OPTIONS preflight requests
    @options method OPTIONS

    # 2. Handle OPTIONS requests directly in Caddy
    handle @options {
        header Access-Control-Allow-Origin      "https://dashboard.example.com"
        header Access-Control-Allow-Methods     "GET, POST, PUT, DELETE, PATCH, OPTIONS"
        header Access-Control-Allow-Headers     "Content-Type, Authorization, X-Requested-With"
        header Access-Control-Max-Age           "86400"
        
        # Return 204 (No Content) directly without forwarding to the backend
        respond "" 204
    }

    # 3. Add CORS headers for regular requests (GET, POST, etc.)
    header Access-Control-Allow-Origin  "https://dashboard.example.com"
    header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS"
    
    # 4. Forward traffic to our backend application
    reverse_proxy localhost:8080
}

Configuration Line Explanations #

  • @options method OPTIONS: You create a named matcher @options that only matches requests with the OPTIONS HTTP method. This ensures the preflight route is caught precisely.
  • handle @options { ... }: A special handling block to intercept preflight requests. It’s very important to directly return a response from Caddy (respond "" 204) so your backend application on port 8080 isn’t burdened by OPTIONS requests. This significantly increases your backend server’s computational efficiency.
  • Access-Control-Max-Age "86400": You instruct the browser to cache this preflight information for 24 hours (86,400 seconds). This greatly helps frontend application performance by removing the extra delay from repeated preflight requests.

CORS with Credentials (Cookies & Auth Headers) #

When your frontend application needs to send credential information — like Session Cookies, HTTP Authorization headers (e.g., custom JWT tokens), or client TLS certificates — you must explicitly enable credential support in your CORS configuration.

Browser Security Rules for Credentials #

If a cross-domain request includes credentials (e.g., the fetch API option { credentials: 'include' } is set), the browser applies very strict security rules:

  1. Must include Access-Control-Allow-Credentials: true on the server response. If this header is missing or false, the browser refuses to hand the response data to your frontend.
  2. NEVER use the wildcard * on the Access-Control-Allow-Origin header. If the server returns Access-Control-Allow-Origin: * while the request includes credentials, the browser blocks the request for security. The server must return a specific, valid origin domain.

Caddyfile Writing for Credentials #

# Example: CORS Configuration with Strict Credentials
api.example.com {
    @options method OPTIONS

    handle @options {
        # ✓ CORRECT: Specify the origin specifically, not using a wildcard (*)
        header Access-Control-Allow-Origin      "https://dashboard.example.com"
        header Access-Control-Allow-Credentials "true"
        
        header Access-Control-Allow-Methods     "GET, POST, PUT, DELETE, PATCH, OPTIONS"
        header Access-Control-Allow-Headers     "Content-Type, Authorization, Cookie, X-CSRF-Token"
        header Access-Control-Max-Age           "86400"
        respond "" 204
    }

    # CORS headers for regular requests
    header Access-Control-Allow-Origin      "https://dashboard.example.com"
    header Access-Control-Allow-Credentials "true"
    
    # Expose custom headers so they can be read by our frontend JavaScript
    header Access-Control-Expose-Headers    "X-Total-Count, X-Request-ID"

    reverse_proxy localhost:8080
}

Using Access-Control-Expose-Headers #

By default, browsers restrict your frontend JavaScript’s access to read response headers. Frontends are only allowed to read a few basic headers like Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, and Pragma.

If your backend application sends important information through custom headers — for example the total data count for pagination in X-Total-Count, or the unique tracking ID in X-Request-ID — you must add them to the Access-Control-Expose-Headers Caddyfile header so your frontend JavaScript can access them through the response.headers.get() method.


Managing Many Origins Dynamically (Dynamic Whitelist) #

One of the biggest limitations of the CORS HTTP header specification is that the Access-Control-Allow-Origin value can only be a wildcard * or a single URL domain. You’re not allowed to include a comma-separated domain list like this:

// ANTI-PATTERN: This comma-separated format is invalid and will be rejected by browsers
Access-Control-Allow-Origin: https://dashboard.example.com, https://admin.example.com

So, what if you have several legitimate frontend environments? For example, a local environment for development (http://localhost:3000), a staging environment (https://staging.example.com), and a production environment (https://dashboard.example.com).

The Dynamic Solution in Caddy #

The solution for this scenario is checking the Origin header sent by the client browser request. If that origin is in your whitelist, you take that origin string and dynamically return it to the Access-Control-Allow-Origin response header.

You can use Caddy’s internal variable {http.request.header.Origin} (or simply abbreviated {header.Origin}) to do this.

Let’s look at the evaluation logic flow in Caddy in the following diagram:

flowchart TD
    Start["1. Request Arrives at Caddy"] --> GetOrigin["2. Read the 'Origin' Header from the Client"]
    GetOrigin --> CheckMatch{"3. Is the Origin in the Whitelist?\n(e.g., localhost:3000, dashboard.example.com)"}
    
    CheckMatch -- "Yes" --> IsOptions{"4. Is it an OPTIONS Method (Preflight)?"}
    CheckMatch -- "No" --> RejectCORS["5. Forward the Request without CORS Headers\n(The browser will block the response)"]
    
    IsOptions -- "Yes" --> SetFullCORS["6. Set the response with:\nAccess-Control-Allow-Origin = {header.Origin}\nAccess-Control-Allow-Credentials = true\nAccess-Control-Max-Age = 86400"]
    SetFullCORS --> Return204["7. Return a 204 status response\n(Directly from Caddy)"]
    
    IsOptions -- "No" --> SetSimpleCORS["8. Set the response with:\nAccess-Control-Allow-Origin = {header.Origin}\nAccess-Control-Allow-Credentials = true"]
    SetSimpleCORS --> ForwardBackend["9. Forward the actual request to the Backend\n(reverse_proxy)"]

Dynamic Whitelist Caddyfile Writing #

Here’s the Caddyfile configuration to implement the logic flow above:

# Example: CORS with Dynamic Domain Whitelist & Credentials
api.example.com {
    # 1. Define a matcher to detect allowed origins
    @allowed_origins {
        header Origin "http://localhost:3000"
        header Origin "https://staging.example.com"
        header Origin "https://dashboard.example.com"
        header Origin "https://admin.example.com"
    }

    # 2. Define a matcher to detect OPTIONS preflight requests
    @options method OPTIONS

    # 3. Handle preflight requests for allowed origins
    handle @options {
        # Use a built-in matcher to validate the request origin
        @options_allowed {
            expression {header.Origin} != ""
            header Origin "http://localhost:3000"
            header Origin "https://staging.example.com"
            header Origin "https://dashboard.example.com"
            header Origin "https://admin.example.com"
        }
        
        handle @options_allowed {
            # Dynamically return the client's request origin
            header Access-Control-Allow-Origin      "{header.Origin}"
            header Access-Control-Allow-Credentials "true"
            
            # Tell cache proxies/browsers to store the response based on the Origin
            header Vary                             "Origin"
            
            header Access-Control-Allow-Methods     "GET, POST, PUT, DELETE, PATCH, OPTIONS"
            header Access-Control-Allow-Headers     "Content-Type, Authorization, Cookie"
            header Access-Control-Max-Age           "86400"
            respond "" 204
        }
        
        # If the OPTIONS preflight request is from a NOT-allowed origin
        # Reject it with a 400 Bad Request status
        respond "Origin Not Allowed" 400
    }

    # 4. Handle regular requests from allowed origins
    handle @allowed_origins {
        header Access-Control-Allow-Origin      "{header.Origin}"
        header Access-Control-Allow-Credentials "true"
        header Vary                             "Origin"
        
        reverse_proxy localhost:8080
    }

    # 5. Handle requests without an origin (e.g., from native mobile apps or curl)
    # or requests from origins not registered in the whitelist
    handle {
        # Directly forward to the backend without adding CORS headers
        reverse_proxy localhost:8080
    }
}

The Importance of the Vary: Origin Header #

[!WARNING] Always include the Vary: Origin header when using dynamic domains. If you don’t include the Vary: Origin header, intermediary caching servers (like CDNs, Cloudflare CDN, or browser proxies) may cache the response with Access-Control-Allow-Origin: https://dashboard.example.com for the first visitor. When the next visitor arrives using the https://admin.example.com origin, the CDN serves that cached page. The second visitor’s browser rejects the response because it detects the origin mismatch. The Vary: Origin header tells caching servers to cache responses separately for each different request Origin header value.


CORS for Public vs Private APIs #

Depending on the type of service you offer, you must distinguish how CORS is handled for public APIs intended for third-party integration, and private APIs used internally.

CORS Characteristic Comparison #

Analysis CriteriaPublic API (e.g. Weather Service, Fonts)Private API (e.g. Admin Dashboard, User Service)
Credentials (Cookies/JWT)Disabled (credentials: 'omit')Enabled (credentials: 'include')
Allow-Origin ValueWildcard *Specific origin (static or dynamic)
Security Risk LevelVery Low (Open data)Very High (Sensitive data access)
Vary Header NeedNot needed (Same for all origins)Very mandatory (Vary: Origin for caching)
Allowed HTTP MethodsGenerally only GET and OPTIONSAll methods (GET, POST, PUT, DELETE, etc.)

Public API Implementation in Caddy #

For public APIs that don’t store sensitive user session data, you can use a simple wildcard configuration that’s very CDN-caching-friendly:

# Example: Open Access Public API Configuration
public-api.example.com {
    @options method OPTIONS

    handle @options {
        header Access-Control-Allow-Origin  "*"
        header Access-Control-Allow-Methods "GET, POST, OPTIONS"
        header Access-Control-Allow-Headers "Content-Type, Authorization"
        header Access-Control-Max-Age       "86400"
        respond "" 204
    }

    # Allow all domains to access our public GET endpoints
    header Access-Control-Allow-Origin  "*"
    header Access-Control-Allow-Methods "GET, POST, OPTIONS"

    reverse_proxy localhost:8080
}

Why Is CORS at the Proxy Level (Caddy) Better Than in the Backend Application? #

Many beginner developers configure CORS inside their application code (e.g., using the Express.js cors() middleware, Java Spring @CrossOrigin annotations, or Django’s CORS library). Although this works, moving CORS logic to the reverse proxy level like Caddy provides several significant performance and architecture advantages:

  1. Backend Resource Efficiency: OPTIONS preflight request handling is cut directly in Caddy (respond "" 204). Your backend application (Node.js, Python, Ruby, Java) doesn’t need to spend CPU threads, memory allocations, or database connections just to process preflight requests that carry no actual data payload.
  2. Preflight Response Speed: Caddy is written in the very fast Go programming language. Preflight responses from Caddy can return to client browsers in milliseconds, far faster than if those requests had to enter your backend application through the framework routing stack.
  3. Security Policy Centralization: You can manage CORS policies in one place (the Caddyfile) for dozens of backend microservices written in different programming languages. This prevents internal server-level security policy inconsistencies.

Troubleshooting and Handling Common CORS Errors #

CORS issues are among the most frequently encountered problems in modern web application development. Here’s a list of common errors often reported in browser developer consoles along with concrete solutions.

1. Error: “No ‘Access-Control-Allow-Origin’ header is present” #

Browser Console Error Message:

Access to fetch at 'https://api.example.com/data' from origin 'https://dashboard.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Possible Causes & Solutions:

  • Cause A: CORS configuration hasn’t been added to the Caddyfile for the api.example.com domain.
    • Solution: Add the header Access-Control-Allow-Origin directive per the guide above.
  • Cause B: The origin domain sent by the browser (https://dashboard.example.com) isn’t registered or is misspelled in the Caddyfile whitelist.
    • Solution: Carefully check the domain spelling, scheme (http vs https), and port.
  • Cause C: The backend API server is experiencing an internal error (HTTP 500 Internal Server Error) or data not found (HTTP 404 Not Found). By default in Caddy, if the backend returns an error, the standard header directive isn’t executed so the CORS headers disappear from the response.
    • Solution: Use the defer keyword on the header directive in the Caddyfile. The defer option instructs Caddy to add the headers right before the response is written to the network, regardless of the HTTP status code produced by the backend.
# Solution Example: Using the 'defer' option to guarantee CORS headers stay present during Backend errors
header Access-Control-Allow-Origin "https://dashboard.example.com" {
    defer
}

2. Error: “Access-Control-Allow-Origin must not be wildcard ‘*’ when credentials mode is ‘include’” #

Browser Console Error Message:

Access to fetch at 'https://api.example.com/data' from origin 'https://dashboard.example.com' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

Cause & Solution: The frontend application sends a request with the credentials option enabled (like including cookies), but on the Caddy side, you set the Access-Control-Allow-Origin header to the wildcard *.

  • Solution: Change the Caddyfile configuration to return a specific domain (static or using the dynamic whitelist approach {header.Origin}).

3. Error: “Response to preflight request doesn’t pass access control check: It does not have HTTP ok status” #

Browser Console Error Message:

Access to fetch at 'https://api.example.com/data' from origin 'https://dashboard.example.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

Cause & Solution: The OPTIONS preflight request sent by the browser is blocked or fails to be processed by Caddy or the backend. For example, you protect the API endpoint with Basic Auth, so the OPTIONS request is also rejected with 401 Unauthorized status because it doesn’t carry authentication credentials.

  • Solution: You must ensure that requests with the OPTIONS method are exempted from authentication checks, IP restrictions, or rate limiting. Browsers never include authentication credentials on OPTIONS preflight requests.
# Solution Example: Exempting OPTIONS routes from Basic Auth
api.example.com {
    # Matcher for non-OPTIONS requests
    @needs_auth {
        not method OPTIONS
        path /admin/*
    }
    
    # Basic auth only runs if the method is NOT OPTIONS
    basicauth @needs_auth {
        admin $2a$14$8lGvWLMR9jGg2.bSZlAHOeYuI1FjTPExECWQpkLPMH1y0LkJnbEKy
    }
    
    # OPTIONS preflight requests are handled directly above
    @options method OPTIONS
    handle @options {
        header Access-Control-Allow-Origin      "https://dashboard.example.com"
        header Access-Control-Allow-Methods     "GET, POST, PUT, DELETE, OPTIONS"
        header Access-Control-Allow-Headers     "Content-Type, Authorization"
        header Access-Control-Max-Age           "86400"
        respond "" 204
    }
    
    header Access-Control-Allow-Origin "https://dashboard.example.com"
    reverse_proxy localhost:8080
}

CORS Testing Simulation Using curl #

You don’t always need to open a browser and trigger JavaScript scripts to test whether your CORS configuration in Caddy is correct. You can do fast, accurate testing using the curl terminal command.

1. Testing the OPTIONS Preflight Request #

To simulate the OPTIONS preflight request sent by browsers, you must manually send the Origin, Access-Control-Request-Method, and Access-Control-Request-Headers request headers:

# Run the following curl command in your terminal
curl -v -X OPTIONS https://api.example.com/data \
  -H "Origin: https://dashboard.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Authorization"

Expected Response Output (If Successful):

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://dashboard.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Access-Control-Allow-Credentials: true
Content-Length: 0

2. Testing Regular Requests (Actual Request) #

To test whether regular responses from the server include the appropriate CORS headers:

# Test a regular GET request by including the Origin header
curl -i https://api.example.com/data \
  -H "Origin: https://dashboard.example.com"

Expected Response Output:

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://dashboard.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

{
  "status": "success",
  "data": []
}

Reusable CORS Configuration Snippet #

If you manage many API domains in one Caddyfile configuration file, rewriting the @options block and CORS header configuration in every server block makes your Caddyfile very long and hard to maintain.

To uphold the DRY principle (Don’t Repeat Yourself), you can wrap the entire CORS configuration into a custom Caddyfile Snippet that can be easily re-imported.

CORS Snippet Writing #

# 1. Define the Reusable Snippet for our Internal CORS
(cors_internal) {
    # Determine the origin domain dynamically using the snippet argument {args.0}
    @options method OPTIONS
    handle @options {
        header Access-Control-Allow-Origin      "{args.0}"
        header Access-Control-Allow-Credentials "true"
        header Access-Control-Allow-Methods     "GET, POST, PUT, DELETE, PATCH, OPTIONS"
        header Access-Control-Allow-Headers     "Content-Type, Authorization, X-Requested-With, X-CSRF-Token"
        header Access-Control-Max-Age           "86400"
        respond "" 204
    }

    header Access-Control-Allow-Origin      "{args.0}"
    header Access-Control-Allow-Credentials "true"
    header Vary                             "Origin"
}

# 2. Implement the snippet in the API v1 Server Block
api-v1.example.com {
    # Import the snippet and pass the allowed frontend domain as the first argument
    import cors_internal "https://dashboard.example.com"

    reverse_proxy localhost:8081
}

# 3. Implement the snippet in the API v2 Server Block
api-v2.example.com {
    # You can specify a different domain if needed
    import cors_internal "https://admin.example.com"

    reverse_proxy localhost:8082
}

With the snippet, if you ever need to add a new custom header to your Access-Control-Allow-Headers list (e.g., a custom monitoring integration header), you only need to edit it once inside the (cors_internal) snippet block. That change is automatically applied to all API server blocks importing the snippet when you trigger a Caddy configuration reload.


Summary #

  • CORS Isn’t a Caddy Add-on Feature: Caddy handles CORS entirely using the built-in header directive and matchers — no external modules need to be installed.
  • OPTIONS Preflight Requests: Must always be cut directly at the Caddy level using the @options method OPTIONS matcher and an empty 204 No Content response to save your backend application’s processing load.
  • Credential Security: When frontend requests include cookies or custom authentication tokens, Access-Control-Allow-Credentials must be set to true, and the Access-Control-Allow-Origin value must not use the wildcard *.
  • Dynamic Whitelist: You can match the client’s Origin request header using Caddy matchers, then dynamically return its value through the {header.Origin} placeholder.
  • Vary Header: Always add the Vary: Origin response header when returning dynamic origins to avoid response caching errors at the CDN or browser proxy level.
  • Hidden CORS Error Cause: If the backend application experiences internal failures (HTTP 5xx or 4xx statuses), make sure you use the defer option on the header directive in Caddy so CORS response headers aren’t removed.

← Previous: Security Headers   Next: Middleware →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact