How to Handle CORS Preflight Request Failure in Gin Framework
Quick answer
Your frontend's request to a Gin API fails in the browser with a CORS error, even though the same request works fine from curl or Postman. This is almost...
Your frontend's request to a Gin API fails in the browser with a CORS error, even though the same request works fine from curl or Postman. This is almost always a failed OPTIONS preflight request β the browser's own security check running before your actual request, which your Gin server isn't answering correctly.
The Problem
The browser console shows a CORS error instead of your actual API response:
Access to fetch at 'http://localhost:8080/api/users' from origin 'http://localhost:3000'
has been blocked by CORS policy: Response to preflight request doesn't pass access
control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Checking the network tab reveals the actual failing request isn't your GET or POST β it's an OPTIONS request that returns a 404 or 405 instead of a successful preflight response:
OPTIONS /api/users HTTP/1.1
Host: localhost:8080
HTTP/1.1 404 Not Found
curl doesn't show this problem because curl, unlike a browser, doesn't send a preflight OPTIONS request or enforce CORS at all β which is why "it works in curl but not the browser" is the classic symptom of this exact issue.
Why It Happens
For "non-simple" cross-origin requests β anything using PUT, DELETE, custom headers, or a Content-Type of application/json β browsers automatically send an OPTIONS request first to ask the server for permission before sending the real one. If the server doesn't explicitly answer that OPTIONS request with the right Access-Control-Allow-* headers, the browser blocks the actual request entirely, and your handler code for the real endpoint never even runs. This happens because:
- Gin has no route registered for
OPTIONSon that path, so it falls through to a 404, which the browser treats as a failed preflight. - CORS middleware exists but is registered after the routes, or only applied to specific routes instead of globally.
- The middleware sets
Access-Control-Allow-Originbut doesn't setAccess-Control-Allow-MethodsorAccess-Control-Allow-Headers, so the preflight technically succeeds but the browser still blocks specific headers or methods it didn't approve. - The allowed origin is hardcoded to production and doesn't include your local development origin (
http://localhost:3000, for example).
The Fix
The reliable approach is the official gin-contrib/cors middleware instead of hand-rolling header logic. Install it:
go get github.com/gin-contrib/cors
Register it before any routes are defined, so it applies globally, including to the automatic preflight handling:
import "github.com/gin-contrib/cors"
router := gin.Default()
router.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:3000", "https://yourapp.com"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
This middleware automatically intercepts and responds to OPTIONS preflight requests before they ever reach your route handlers, so you don't need to manually register an OPTIONS route for every endpoint.
If you need to handle it manually instead (for example, in a minimal setup without the contrib package), register an explicit OPTIONS handler and set headers before any route logic runs:
router.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "http://localhost:3000")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
})
Still Not Working?
If preflight succeeds but you're using cookies or credentials and still get blocked, check that AllowCredentials is set to true on the server and that you're never using a wildcard "*" for AllowOrigins at the same time β browsers reject the combination of credentials with a wildcard origin outright, so you have to list explicit origins:
AllowOrigins: []string{"http://localhost:3000"}, // not "*"
AllowCredentials: true,
On the frontend side, also confirm the request itself is sending credentials, since a missing credentials: 'include' in fetch() will silently drop cookies even when the server-side CORS config is completely correct:
fetch('http://localhost:8080/api/users', { credentials: 'include' })
It's also worth understanding why a preflight only happens for certain requests and not every cross-origin call. Browsers classify requests as "simple" β skipping the preflight entirely β only when they use GET, HEAD, or POST with specific content types like text/plain, and don't set any custom headers. The moment your frontend sends JSON with Content-Type: application/json, uses PUT or DELETE, or adds an Authorization header, the browser is required to preflight it first, regardless of how simple the actual API call looks. This is why a basic GET request to the same API often works fine without any CORS configuration at all, while a POST with a JSON body fails β they're not treated the same way by the browser's security model.
If you're debugging this locally and want to see exactly what the browser is sending during preflight, inspect the request headers directly rather than guessing:
curl -X OPTIONS http://localhost:8080/api/users \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type" \
-i
A correctly configured Gin server should respond with a 204 or 200 status and the matching Access-Control-Allow-* headers echoed back β if any of them are missing from this response, that's the specific header your middleware configuration needs to add.