Caddy’s Forward Auth is less about what it protects and more about how it lets you delegate that decision entirely to another service.
Let’s see it in action. Imagine you have a private API at api.example.com that you want to protect. You’ll use Caddy as a reverse proxy and configure it to ask an external "auth service" (which we’ll also mock up simply) if a request is allowed.
First, Caddyfile:
api.example.com {
reverse_proxy localhost:8080 {
header_up X-Forwarded-For {remote_host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
}
forward_auth {
uri http://localhost:8081/auth
method GET
auth_response 200
# Include headers from the original request in the auth check
scope *
}
}
Here, api.example.com is our protected resource. Caddy will proxy requests to localhost:8080. Before it does that, it hits the forward_auth directive. It sends a GET request to http://localhost:8081/auth and expects a 200 OK from that service to allow the request through. The scope * tells Caddy to send all incoming requests to the auth service.
Now, the "auth service" itself, running on localhost:8081. This is a minimal Go program:
package main
import (
"fmt"
"log"
"net/http"
)
func authHandler(w http.ResponseWriter, r *http.Request) {
// In a real app, you'd check cookies, JWTs, session IDs, etc.
// For this example, we'll just check for a specific header.
authHeader := r.Header.Get("X-Auth-Token")
if authHeader == "supersecrettoken123" {
log.Printf("Auth successful for %s", r.URL.Path)
w.WriteHeader(http.StatusOK) // Allow request
} else {
log.Printf("Auth failed for %s, missing or invalid X-Auth-Token", r.URL.Path)
w.WriteHeader(http.StatusUnauthorized) // Deny request
}
}
func main() {
http.HandleFunc("/auth", authHandler)
fmt.Println("Auth service listening on :8081")
log.Fatal(http.ListenAndServe(":8081", nil))
}
This Go application listens on port 8081 and has a single endpoint /auth. It checks for a custom header X-Auth-Token. If it’s present and matches "supersecrettoken123", it returns 200 OK. Otherwise, it returns 401 Unauthorized.
When a client requests api.example.com/data, Caddy first sends a request to http://localhost:8081/auth. The Host, X-Forwarded-For, X-Real-IP, and X-Forwarded-Proto headers from the original request are forwarded to the auth service by default. If the client also sent an X-Auth-Token: supersecrettoken123 header, the Go app would respond with 200 OK, and Caddy would then proxy the original request to localhost:8080. If the X-Auth-Token was missing or incorrect, the Go app would respond with 401 Unauthorized, and Caddy would stop processing and return 401 Unauthorized to the client.
The beauty here is that Caddy doesn’t need to know how authentication works. It just needs a service that speaks HTTP and returns a specific status code. This allows you to use existing identity providers, OAuth flows, or custom authentication logic without Caddy needing to implement it. You can also specify which headers Caddy should not forward to the auth service using the no_trace subdirective, which is useful for preventing sensitive headers from being leaked.
The auth_response directive can take multiple status codes, separated by commas. If the auth service returns any of these codes, Caddy considers the request authenticated. This gives you flexibility if your auth service uses different codes for different success scenarios.
If you want the auth service to be able to set cookies on the client’s browser that Caddy should then forward back, you’d need to configure Caddy to pass those cookies through. Caddy’s forward_auth directive implicitly forwards Set-Cookie headers from the auth service response back to the client. However, it’s crucial that your auth service itself is configured to send these cookies correctly.
The next step is often integrating a more robust authentication flow, like delegating to an OpenID Connect provider, which would involve adding more complex logic to your auth service and potentially using Caddy’s forward_auth to redirect users to a login page.