The gin-contrib/cors middleware is the de-facto CORS solution for Go applications built on the Gin framework. But its default configuration and several common patterns introduce CORS misconfigurations ranging from wildcard policies on credentialed APIs to subtle origin-allowlist bypass bugs. This guide covers every gin-contrib/cors vulnerability pattern, exactly how each one is exploited, and the correct configuration for each scenario.
Quick Fix Reference
If you just need the answer:
// SECURE gin-contrib/cors configuration for authenticated APIs
import "github.com/gin-contrib/cors"
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowOrigins: []string{
"https://app.yourcompany.com",
"https://yourcompany.com",
},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
MaxAge: 86400,
}))
Never use AllowAllOrigins: true or AllowOrigins: []string{"*"} with AllowCredentials: true. Scroll down for a full explanation of why — and what to do if you need dynamic origin matching.
Understanding gin-contrib/cors Configuration Fields
Before fixing anything, it helps to understand what each configuration field actually does:
| Field | Type | What It Controls |
|---|---|---|
AllowAllOrigins | bool | If true, sets Access-Control-Allow-Origin: * |
AllowOrigins | []string | Exact origin strings to allow; "*" in this slice = AllowAllOrigins behavior |
AllowOriginFunc | func(string) bool | Custom function called with the request’s Origin; return true to allow |
AllowCredentials | bool | Sets Access-Control-Allow-Credentials: true |
AllowMethods | []string | Methods listed in the preflight response |
AllowHeaders | []string | Request headers the browser may send |
MaxAge | int | Seconds the preflight response may be cached |
The critical constraint from the CORS spec: Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true cannot be combined. Browsers block credentialed cross-origin requests when the origin is *. Any configuration that tries to do both is broken by design — and the attempted “fix” often introduces a more dangerous vulnerability.
Vulnerability Pattern 1: AllowAllOrigins with AllowCredentials
This is the most common gin-contrib/cors misconfiguration.
Vulnerable Code
// VULNERABLE — browsers block this, but developers then "fix" it incorrectly
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowCredentials: true, // ← Browsers reject "Origin: *" + credentials
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
}))
When this configuration is deployed:
- Browsers receive
Access-Control-Allow-Origin: *withAccess-Control-Allow-Credentials: true - The CORS spec requires browsers to reject this combination
- The frontend gets a CORS error
- A developer “fixes” it by switching to origin reflection
The Dangerous “Fix” That Follows
// DANGEROUS FIX — reflects any origin unconditionally
r.Use(cors.New(cors.Config{
AllowOriginFunc: func(origin string) bool {
return true // ← Reflects any origin back — functionally "AllowAllOrigins" with credentials
},
AllowCredentials: true,
}))
When AllowOriginFunc returns true, gin-contrib/cors reflects the request’s Origin value back in the Access-Control-Allow-Origin response header. With AllowCredentials: true, this allows any website to make credentialed cross-origin requests to your API and read the responses — a critical CORS misconfiguration.
The Correct Fix
Use a fixed allowlist. If you genuinely need to allow all origins for a public unauthenticated API, use AllowAllOrigins: true without AllowCredentials: true:
// For public, unauthenticated APIs — wildcard is acceptable
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
// No AllowCredentials — wildcard + credentials is invalid
AllowMethods: []string{"GET", "OPTIONS"},
AllowHeaders: []string{"Content-Type"},
MaxAge: 3600,
}))
For authenticated APIs, use an explicit allowlist:
// For authenticated APIs — explicit allowlist required
r.Use(cors.New(cors.Config{
AllowOrigins: []string{
"https://app.yourcompany.com",
"https://yourcompany.com",
},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-API-Key"},
AllowCredentials: true,
MaxAge: 86400,
}))
Vulnerability Pattern 2: AllowOriginFunc with Substring Check
This is the gin-contrib/cors “off-by-one” pattern: the developer implements a custom AllowOriginFunc with a string check that is almost correct but contains a boundary error.
Vulnerable Code
// VULNERABLE — strings.Contains allows "evil-yourcompany.com"
r.Use(cors.New(cors.Config{
AllowOriginFunc: func(origin string) bool {
return strings.Contains(origin, "yourcompany.com")
// ✓ https://yourcompany.com (passes)
// ✓ https://app.yourcompany.com (passes)
// ✗ https://evil-yourcompany.com (ALSO passes — bypass!)
// ✗ https://yourcompany.com.attacker.io (ALSO passes — bypass!)
},
AllowCredentials: true,
}))
The strings.Contains check is off by one domain boundary: it checks whether yourcompany.com appears anywhere in the origin string, but doesn’t verify it appears at the right position (as the full hostname or a proper subdomain).
An attacker who registers evil-yourcompany.com or yourcompany.com.attacker.io passes this check.
Another Common Substring Variant
// ALSO VULNERABLE — HasPrefix doesn't check the right boundary
r.Use(cors.New(cors.Config{
AllowOriginFunc: func(origin string) bool {
return strings.HasPrefix(origin, "https://yourcompany.com")
// ✓ https://yourcompany.com (passes)
// ✓ https://yourcompany.com/... (passes, but origins don't have paths)
// ✗ https://yourcompany.com.attacker.io (PASSES — bypass!)
},
AllowCredentials: true,
}))
HasPrefix is also wrong because yourcompany.com.attacker.io starts with yourcompany.com (as a subdomain).
The Correct AllowOriginFunc
Fix the boundary check by parsing the URL and using strings.HasSuffix with a leading dot, which enforces the domain boundary:
import (
"net/url"
"strings"
"github.com/gin-contrib/cors"
)
func makeAllowOriginFunc(allowedDomains []string) func(string) bool {
// Build a set for O(1) exact lookup
allowedSet := make(map[string]struct{}, len(allowedDomains))
for _, d := range allowedDomains {
allowedSet[d] = struct{}{}
}
return func(origin string) bool {
if origin == "" {
return false
}
// Fast path: exact match
if _, ok := allowedSet[origin]; ok {
return true
}
// Subdomain match with proper dot-boundary check
parsed, err := url.Parse(origin)
if err != nil || parsed.Scheme != "https" {
return false
}
host := parsed.Hostname()
for domain := range allowedSet {
// Parse the allowed domain to extract just the hostname
allowedParsed, err := url.Parse(domain)
if err != nil {
continue
}
allowedHost := allowedParsed.Hostname()
// HasSuffix with a leading dot ensures:
// app.yourcompany.com → passes (ends with ".yourcompany.com")
// evil-yourcompany.com → REJECTED (doesn't end with ".yourcompany.com")
if strings.HasSuffix(host, "."+allowedHost) {
return true
}
}
return false
}
}
// Usage
r.Use(cors.New(cors.Config{
AllowOriginFunc: makeAllowOriginFunc([]string{
"https://yourcompany.com",
"https://app.yourcompany.com",
}),
AllowCredentials: true,
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
MaxAge: 86400,
}))
Test the fix:
// Unit tests for the allowlist function
func TestAllowOriginFunc(t *testing.T) {
fn := makeAllowOriginFunc([]string{"https://yourcompany.com"})
cases := []struct {
origin string
want bool
}{
{"https://yourcompany.com", true}, // Exact match
{"https://app.yourcompany.com", true}, // Subdomain
{"https://evil-yourcompany.com", false}, // Suffix bypass attempt
{"https://yourcompany.com.attacker.io", false}, // Subdomain bypass attempt
{"http://yourcompany.com", false}, // HTTP not HTTPS
{"https://notyourcompany.com", false}, // Different domain
{"", false}, // Empty origin
{"null", false}, // Null origin (sandboxed iframe)
}
for _, tc := range cases {
got := fn(tc.origin)
if got != tc.want {
t.Errorf("AllowOriginFunc(%q) = %v, want %v", tc.origin, got, tc.want)
}
}
}
Vulnerability Pattern 3: AllowOrigins with "*" and AllowCredentials
Placing "*" in the AllowOrigins slice is equivalent to setting AllowAllOrigins: true. Combined with AllowCredentials: true, this is the invalid wildcard+credentials combination:
// VULNERABLE — "*" in AllowOrigins slice behaves like AllowAllOrigins
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, // ← Same as AllowAllOrigins: true
AllowCredentials: true, // ← Browsers reject this combination
}))
The gin-contrib/cors library treats a "*" entry in AllowOrigins as a wildcard, not as a literal origin string. The fix is to replace "*" with explicit origin strings.
Vulnerability Pattern 4: Regex-Based AllowOriginFunc Without Anchors
Some teams implement regex matching in AllowOriginFunc for flexibility. Unanchored regexes have the same boundary-check problem as substring matching:
import "regexp"
// VULNERABLE — unanchored regex matches anywhere in the string
var allowedPattern = regexp.MustCompile(`yourcompany\.com`)
r.Use(cors.New(cors.Config{
AllowOriginFunc: func(origin string) bool {
return allowedPattern.MatchString(origin)
// regexp.MatchString finds the pattern anywhere — not full-string match
// https://yourcompany.com.attacker.io → PASSES
// https://notyourcompany.com → PASSES
},
AllowCredentials: true,
}))
The Fix: Anchored Regex
Use ^ (start) and $ (end) anchors to require a full-string match:
// SECURE — anchored regex with full-string match
var allowedPattern = regexp.MustCompile(
`^https://([a-zA-Z0-9][a-zA-Z0-9\-]*\.)?yourcompany\.com$`,
)
r.Use(cors.New(cors.Config{
AllowOriginFunc: func(origin string) bool {
if origin == "" {
return false
}
return allowedPattern.MatchString(origin)
// https://yourcompany.com → ✓
// https://app.yourcompany.com → ✓
// https://evil-yourcompany.com → ✗ (no dot before yourcompany)
// https://yourcompany.com.attacker.io → ✗ (anchored at end)
},
AllowCredentials: true,
}))
Note: Go’s regexp.MatchString (and regexp.Regexp.MatchString) finds the pattern anywhere in the string unless anchors are used. This is equivalent to Python’s re.search(). Always use ^ and $ anchors for CORS origin validation.
Vulnerability Pattern 5: Missing Vary: Origin Header
When you use AllowOriginFunc or a dynamic allowlist, gin-contrib/cors automatically adds the Vary: Origin header. However, if you implement custom CORS middleware alongside gin-contrib/cors, or if you override headers manually, you may strip this header.
Why Vary: Origin Matters
When your CORS response includes a specific allowed origin (Access-Control-Allow-Origin: https://app.yourcompany.com), and a CDN or reverse proxy caches that response, the cache might serve the same response to a different origin — with the wrong Access-Control-Allow-Origin value.
Vary: Origin instructs CDNs and proxies to cache CORS responses separately per Origin header value, preventing this cross-origin cache poisoning.
// gin-contrib/cors adds Vary: Origin automatically when using AllowOriginFunc
// or AllowOrigins with a specific list (not wildcard)
// If you add custom middleware that sets CORS headers manually, always include:
c.Header("Vary", "Origin")
Complete Secure gin-contrib/cors Configuration
For a Typical Authenticated REST API
package main
import (
"os"
"strings"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func corsMiddleware() gin.HandlerFunc {
// Read allowed origins from environment for deployment flexibility
rawOrigins := os.Getenv("CORS_ALLOWED_ORIGINS")
// e.g., CORS_ALLOWED_ORIGINS=https://app.yourcompany.com,https://yourcompany.com
var allowedOrigins []string
if rawOrigins != "" {
for _, o := range strings.Split(rawOrigins, ",") {
trimmed := strings.TrimSpace(o)
if trimmed != "" {
allowedOrigins = append(allowedOrigins, trimmed)
}
}
}
// Fallback for local development
if len(allowedOrigins) == 0 {
allowedOrigins = []string{"http://localhost:3000", "http://localhost:5173"}
}
return cors.New(cors.Config{
AllowOrigins: allowedOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-API-Key", "X-Request-ID"},
ExposeHeaders: []string{"Content-Length", "X-Request-ID"},
AllowCredentials: true,
MaxAge: 86400, // Cache preflight responses for 24 hours
})
}
func main() {
r := gin.Default()
r.Use(corsMiddleware())
r.GET("/api/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
r.Run(":8080")
}
For a Public API (No Authentication)
func publicAPICORS() gin.HandlerFunc {
return cors.New(cors.Config{
AllowAllOrigins: true, // Wildcard — safe for public unauthenticated data
AllowMethods: []string{"GET", "OPTIONS"},
AllowHeaders: []string{"Content-Type", "Accept"},
// No AllowCredentials — never combine with AllowAllOrigins
MaxAge: 3600,
})
}
For a Multi-Tenant SaaS (Dynamic Subdomain Allowlist)
import (
"net/url"
"strings"
"github.com/gin-contrib/cors"
)
// Allow any subdomain of yourplatform.com plus explicit tenant origins
func multiTenantCORSMiddleware() gin.HandlerFunc {
// Hardcoded base domain — no substring bugs
const baseDomain = "yourplatform.com"
// Explicit additional origins (e.g., custom domains mapped by tenants)
additionalOrigins := map[string]struct{}{
"https://client1.com": {},
"https://app.client2.io": {},
}
return cors.New(cors.Config{
AllowOriginFunc: func(origin string) bool {
if origin == "" {
return false
}
parsed, err := url.Parse(origin)
if err != nil || parsed.Scheme != "https" {
return false
}
host := parsed.Hostname()
// Allow exact base domain
if host == baseDomain {
return true
}
// Allow subdomains with dot-boundary enforcement
if strings.HasSuffix(host, "."+baseDomain) {
return true
}
// Allow additional explicitly-listed origins
if _, ok := additionalOrigins[origin]; ok {
return true
}
return false
},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
MaxAge: 3600,
})
}
Testing gin-contrib/cors Configurations
Use these curl commands to verify your CORS middleware behaves correctly:
# Test 1: Legitimate origin — should receive CORS headers
curl -si \
-H "Origin: https://app.yourcompany.com" \
-H "Access-Control-Request-Method: GET" \
-X OPTIONS \
http://localhost:8080/api/user \
| grep -i "access-control"
# Expected:
# Access-Control-Allow-Origin: https://app.yourcompany.com
# Access-Control-Allow-Credentials: true
# Vary: Origin
# Test 2: Suffix bypass attempt — should be REJECTED
curl -si \
-H "Origin: https://evil-yourcompany.com" \
http://localhost:8080/api/user \
| grep -i "access-control"
# Expected: no Access-Control-Allow-Origin header
# Test 3: Subdomain of attacker's domain — should be REJECTED
curl -si \
-H "Origin: https://yourcompany.com.attacker.io" \
http://localhost:8080/api/user \
| grep -i "access-control"
# Expected: no Access-Control-Allow-Origin header
# Test 4: Null origin (sandboxed iframe) — should be REJECTED
curl -si \
-H "Origin: null" \
http://localhost:8080/api/user \
| grep -i "access-control"
# Expected: no Access-Control-Allow-Origin header
# Test 5: HTTP instead of HTTPS — should be REJECTED (in production)
curl -si \
-H "Origin: http://yourcompany.com" \
http://localhost:8080/api/user \
| grep -i "access-control"
# Expected: no Access-Control-Allow-Origin header (HTTPS only policy)
All five tests should produce the expected results. If test 2 or 3 returns Access-Control-Allow-Origin headers, your AllowOriginFunc has a boundary-check bug.
gin-contrib/cors Version Notes
The middleware’s behavior has evolved across versions. Key changes:
- v1.7.0+:
AllowOriginFuncis the recommended approach for dynamic origin validation;AllowOriginslist is processed with exact matching only (no glob support) - v1.6.0+:
AllowPrivateNetworkfield added for Private Network Access (PNA) headers - All versions:
AllowAllOrigins: truewithAllowCredentials: trueproduces a misconfigured response that browsers reject; the middleware does not error or warn
Always pin your dependency: github.com/gin-contrib/cors v1.7.x and check the changelog when updating.
How SAST Tools Detect gin-contrib/cors Misconfigurations
Static analysis tools that understand Go’s type system can detect these patterns:
AllowAllOrigins: true+AllowCredentials: true: a trivially detectable struct literal patternAllowOriginFuncreturningtrueunconditionally: data-flow analysis showing the function always returnstruestrings.Contains(origin, ...)inAllowOriginFunc: interprocedural analysis flagging substring-based origin checks in CORS validation context
Offensive360 SAST detects CORS misconfiguration patterns in Go, including gin-contrib/cors struct literals and custom AllowOriginFunc implementations, mapping findings to the CORSAllowOriginWildcard and CORSReflectedOrigin rules in the Knowledge Base.
Summary
| Pattern | Risk | Fix |
|---|---|---|
AllowAllOrigins: true + AllowCredentials: true | Critical — browsers block, devs then introduce reflected-origin | Use explicit AllowOrigins list |
AllowOriginFunc always returns true | Critical — any origin allowed with credentials | Return false for non-allowlisted origins |
strings.Contains(origin, "domain.com") | High — domain suffix bypass | Use strings.HasSuffix(host, ".domain.com") with URL parsing |
strings.HasPrefix(origin, "https://domain.com") | High — domain.com.evil.io bypass | Use exact match + HasSuffix with dot |
Unanchored regex domain\.com | High — matches anywhere in origin string | Use ^https://...domain\.com$ anchors |
AllowOrigins: []string{"*"} + AllowCredentials | Critical — same as AllowAllOrigins | Replace "*" with explicit origins |
The safest gin-contrib/cors configuration is always the simplest: a static AllowOrigins slice containing the exact origin strings you want to allow.
Offensive360 SAST detects CORS misconfigurations in Go, including gin-contrib/cors wildcard policies and flawed AllowOriginFunc implementations. Scan your Go API code or browse the CORS vulnerability reference for full remediation details.