CSRF Without Tokens: Cross-Origin Protection in Fiber v3.6
Most CSRF bugs I have seen in Fiber apps were not attacks. They were users who
left a form open over lunch, came back, clicked Save and got a 403 because the
token in the page had expired. Or an HTMX page where one fragment was rendered
without the hx-headers attribute that carries the token. Or a SPA that read
the token cookie on startup and kept sending a stale value after the session
rotated. The token itself was never the hard part. The plumbing was: every
template, every fetch wrapper and every cached page has to carry a value from
the server back to the server, and a single gap turns into a support ticket.
Browsers can tell a server where a request comes from. Every major browser
engine now sends the Sec-Fetch-Site header, and Go 1.25 used it to add
http.CrossOriginProtection to the standard library, a CSRF defense without any
token. Fiber v3.6.0
brings the same check to Fiber as
csrf.NewCrossOriginProtection
(#4596). It sets no cookie, needs
no storage and puts nothing in your templates.
This post protects a small notes service that has three kinds of clients: its own pages, a dashboard on a sibling subdomain, and an identity provider that posts back after login. We will exempt the one route that has to accept cross-site posts, roll the check out in report-only mode next to the existing tokens, pin the policy down in a test, and look at the cases where a token remains the better tool. Everything was built and tested against Fiber v3.6.1. The middleware needs v3.6.0 or later, which in turn requires Go 1.26.
What the Browser Already Tells Youโ
A CSRF attack works because the browser attaches cookies to a request no matter
which site triggered it. A page on evil.example submits a hidden form to
https://notes.example.com/notes, the browser sends the user's session cookie
along, and your handler sees a perfectly authenticated request. A token defends
against this by requiring a value that the attacker's page cannot read. The
tokenless approach asks a different question: did a page on your own origin
start this request?
Two headers answer it. Origin names the origin of the page that started the
request. It has been around for years, but browsers do not send it on every
request, and older ones were inconsistent about it. Sec-Fetch-Site is one of
the Fetch
Metadata
headers and states the relationship directly: same-origin for your own pages,
same-site for a page on another subdomain of the same registrable domain,
cross-site for everything else, and none when the user typed the URL or
opened a bookmark.
Both are forbidden header names. Script on the attacker's page cannot set or
change them, neither with fetch nor with a form. A non-browser client can send
anything it likes, but it does not hold your user's cookies, so there is nothing
for it to forge. The browser has to cooperate, though. According to
caniuse, Chromium-based
browsers sent Sec-Fetch-Site first, Firefox followed with version 90, and
Safari 16.4 closed the gap in March 2023. Browsers also send Fetch Metadata only
to secure origins, which means HTTPS in production and localhost while you
develop.
How It Differs From What You Run Todayโ
The middleware
documentation lists
the decision rules in detail. In short: GET, HEAD and OPTIONS always pass.
Other methods pass when Sec-Fetch-Site is same-origin or none, when the
request carries neither header, when an older browser sends only Origin and
its host equals the Host header, or when Origin is listed in
TrustedOrigins. Everything else is handed to ErrorHandler with
csrf.ErrCrossOriginRequest. A request without either header is let through on
purpose. It is either same-origin from a very old browser or not a browser at
all, so your curl scripts, mobile apps and webhook senders keep working
without configuration.
More interesting than the rules is how they compare with the two defenses most Fiber apps already rely on.
Compared With SameSite Cookiesโ
A session cookie with SameSite=Lax is often treated as CSRF protection on its
own, and against a random attacker site it mostly is. But "site" in SameSite
means the registrable domain. A form on blog.example.com posting to
notes.example.com is same-site, so the browser attaches a Lax or even
Strict cookie. If the blog runs a vulnerable plugin, or a subdomain points at
a cloud bucket somebody else has claimed, that page can make authenticated
requests to your notes service and the cookie attribute will not object.
The cross-origin check closes that gap. It only accepts same-origin, so a
request marked same-site is denied unless its exact origin is in
TrustedOrigins. csrf.New denies it as well, because its origin check also
wants an exact match. So when you move from tokens to the header check, sibling
subdomains are just as blocked as before. When you move from "we have SameSite
cookies" to the header check, they become blocked for the first time, which is
the point.
Compared With csrf.Newโ
The token middleware keeps working as before and is still the right choice in some places, which we will come to. When you switch, these differences change behavior:
csrf.NewtreatsTRACEand the newQUERYmethod as safe. The tokenless check, likenet/http, exempts onlyGET,HEADandOPTIONS. A frontend on another origin that calls yourQUERYendpoints needs to be inTrustedOrigins.OriginandTrustedOriginsare compared as sent. The configured entries are normalized for case and trailing slash, but default ports are not folded:https://dashboard.example.com:443in the config does not match theOrigin: https://dashboard.example.comthat a browser sends. Write origins exactly as a browser serializes them, without the default port.csrf.Newfalls back to theRefererheader on HTTPS whenOriginis missing. The tokenless check never readsReferer.- A request that repeats
Sec-Fetch-Site, or repeatsOriginwhere the decision depends on it, is denied. There is no single value to decide on, so the check fails closed. - The default
ErrorHandlerreturnsfiber.ErrForbidden, so the response goes through your app's error handler. With Fiber's default handler, a SPA receives the plain textForbidden, not your usual JSON error. SetErrorHandlerif your clients parse error bodies.
The Notes Serviceโ
Here is the complete service. It renders a form on /, accepts the form on
POST /notes, offers a JSON API on POST /api/notes for a dashboard at
https://dashboard.example.com, and receives the login result from an identity
provider on POST /auth/callback. The in-memory store stands in for your
database.
package main
import (
"log"
"strings"
"sync"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/csrf"
)
// The dashboard is a separate SPA that calls the JSON API with credentials.
const dashboardOrigin = "https://dashboard.example.com"
type Note struct {
Text string `json:"text" form:"text"`
}
type store struct {
mu sync.Mutex
notes []Note
}
func (s *store) add(n Note) int {
s.mu.Lock()
defer s.mu.Unlock()
s.notes = append(s.notes, n)
return len(s.notes)
}
func crossOriginDenied(c fiber.Ctx, err error) error {
log.Printf("csrf: denied %s %s (Sec-Fetch-Site=%q, Origin=%q): %v",
c.Method(), c.Path(),
c.Get(fiber.HeaderSecFetchSite), c.Get(fiber.HeaderOrigin), err)
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "cross-origin request blocked",
})
}
func newApp() *fiber.App {
app := fiber.New()
notes := &store{}
app.Use(cors.New(cors.Config{
AllowOrigins: []string{dashboardOrigin},
AllowCredentials: true,
}))
app.Use(csrf.NewCrossOriginProtection(csrf.CrossOriginConfig{
TrustedOrigins: []string{dashboardOrigin},
ErrorHandler: crossOriginDenied,
Next: func(c fiber.Ctx) bool {
if c.Method() != fiber.MethodPost {
return false
}
route := c.Endpoint()
return route != nil && route.Name == "oidc.callback"
},
}))
app.Get("/", func(c fiber.Ctx) error {
c.Type("html")
return c.SendString(`<form method="post" action="/notes">
<input name="text"> <button>Save</button>
</form>`)
})
app.Post("/notes", func(c fiber.Ctx) error {
var n Note
if err := c.Bind().Form(&n); err != nil {
return err
}
notes.add(n)
return c.Redirect().Status(fiber.StatusSeeOther).To("/")
})
app.Post("/api/notes", func(c fiber.Ctx) error {
var n Note
if err := c.Bind().JSON(&n); err != nil {
return err
}
if strings.TrimSpace(n.Text) == "" {
return fiber.NewError(fiber.StatusUnprocessableEntity,
"text is required")
}
id := notes.add(n)
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": id})
})
// The identity provider posts the login result back from its own origin
// (OIDC response_mode=form_post), so this one route must accept it.
app.Post("/auth/callback", func(c fiber.Ctx) error {
if c.FormValue("state") == "" {
return fiber.ErrBadRequest
}
// Validate state and exchange the code here.
return c.Redirect().Status(fiber.StatusSeeOther).To("/")
}).Name("oidc.callback")
return app
}
func main() {
app := newApp()
if err := app.Listen(":3000"); err != nil {
log.Fatal(err)
}
}
Compared with a token setup, the form on / has no hidden _csrf field, the
CORS config lists no X-Csrf-Token header, and there is no session store or
__Host- cookie for the token. If you are migrating, all of that can go once
the new check is enforced.
Trying Itโ
Start the service and play the different clients with curl. curl sends neither header unless you ask it to, so a plain call is the server-to-server case:
curl -i -X POST -H 'Content-Type: application/json' \
-d '{"text":"from curl"}' http://localhost:3000/api/notes
HTTP/1.1 201 Created
...
{"id":1}
Now the attack: a hidden form on another site that posts to /notes. A browser
sends such a form without a CORS preflight, because
application/x-www-form-urlencoded, multipart/form-data and text/plain
bodies count as simple requests. That is why a strict CORS config never stopped
CSRF. The browser marks the request as cross-site:
curl -i -X POST -H 'Sec-Fetch-Site: cross-site' \
-H 'Origin: https://evil.example' -d 'text=pwned' \
http://localhost:3000/notes
HTTP/1.1 403 Forbidden
...
{"error":"cross-origin request blocked"}
The custom ErrorHandler logs both headers the decision was based on:
2026/10/18 12:11:33 csrf: denied POST /notes (Sec-Fetch-Site="cross-site", Origin="https://evil.example"): csrf: cross-origin request denied
The dashboard sends Sec-Fetch-Site: same-site together with its own origin and
gets a 201, because that origin is trusted. The same request from
https://blog.example.com, also same-site, gets the 403. When a legitimate
client is blocked in production, these log lines show which header was missing
or unexpected, which the default bare 403 does not.
The Exceptions You Will Needโ
Real applications rarely stay at "only our own pages post to us". The example contains the three exceptions I run into most often, and they need different fixes.
A Frontend on Another Originโ
The dashboard lives on its own subdomain and calls the API with credentials. It
needs two entries: AllowOrigins in the CORS config, so the browser lets it
send JSON and read the response, and TrustedOrigins, so the cross-origin check
lets the write through. The two middleware answer different questions. CORS
controls what a page on another origin may read, the cross-origin check controls
what it may change.
Forgetting the second list is the typical migration bug, and local development
hides it if your frontend dev server proxies API calls. With a proxy setting in
Vite or a similar tool, the browser talks to localhost:5173 for both the page
and the API, every request is same-origin, and nothing is ever denied. In
staging, the dashboard calls the API on its real origin and the first POST
gets a 403. Without such a proxy you would see the same 403 locally, since
localhost:5173 and localhost:3000 are different origins. The example keeps
the origin in one constant and feeds it to both lists. With several frontends,
build both lists from the same slice.
Keep the list short. With csrf.New, a request from a trusted origin still
needs a valid token. Here the origin match is the entire check, so every origin
in TrustedOrigins can make any change your logged-in users can make. Wildcards
such as https://*.example.com are accepted, and they also trust the subdomain
that serves user uploads and the forgotten marketing microsite. List exact
origins. Entries must use http or https; a value like
capacitor://localhost makes the constructor panic at startup with
[CSRF] Invalid origin format in configuration:[redacted]. The redaction is
deliberate and cannot be turned off for this constructor, so when that panic
shows up, look through your origin list by hand.
A Callback From Your Identity Providerโ
OpenID Connect with response_mode=form_post ends a login with an
auto-submitted form from the identity provider's origin to your callback.
Payment flows with 3-D Secure do the same. That POST is cross-site by design,
and you cannot fix it with TrustedOrigins alone. When the form is reached
through a redirect chain across several sites, the browser may send
Origin: null instead of the provider's origin. That value cannot be trusted,
because sandboxed iframes on any site send it too. The route has to be exempted.
The Next function does that. The documentation example compares c.Path(),
which works. The example ties the exemption to the route instead: the callback
is registered with .Name("oidc.callback"), and Next asks
c.Endpoint(), also added in v3.6.0, which route will
handle the request. If someone moves the callback to another path, the exemption
moves with it, and spellings that reach the same handler are covered too, such
as a trailing slash under the default non-strict routing:
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
-H 'Sec-Fetch-Site: cross-site' -H 'Origin: https://id.example.org' \
-d 'state=abc&code=x' http://localhost:3000/auth/callback/
303
This has two costs. c.Endpoint() scans the remaining routes in the request's
tree bucket, which is a second router lookup. Next therefore returns early for
anything that is not a POST, but every POST, including those to
/api/notes, still pays for it. If that matters for your throughput, attach the
middleware to the groups that need it, such as /api, instead of the whole app,
and keep the callback outside of them. Route names are also not unique in Fiber.
A second route that someone else names oidc.callback is silently exempt as
well, so treat the name like a security setting and give it a distinctive value.
An exempted route has no CSRF protection, so it has to protect itself. For OIDC
that is the state parameter, which you compare with the value you stored
before redirecting to the provider. The example only checks that it is present,
which shows where the check goes and is not enough for production.
Webhooks and Other Serversโ
Stripe, GitHub and your own backend services send neither Sec-Fetch-Site nor
Origin, so they pass without an exemption. That is correct, and it also means
the middleware does nothing for those routes. Verify webhook signatures as
before. The cross-origin check only defends against browsers that are tricked
into sending a request.
Rolling It Out in Report-Only Modeโ
Switching an existing application from tokens to the header check carries one
real risk: a legitimate client you did not know about, such as a partner who
embeds your form or an old in-house tool. Before enforcing, run the check for a
while in a mode where it only reports. ErrorHandler can do that. It receives
the request context, and if it calls c.Next() instead of returning an error,
the request continues down the chain:
// reportOnly logs requests the check would block and lets them through.
func reportOnly(c fiber.Ctx, err error) error {
log.Printf("csrf report-only: would deny %s %s "+
"(Sec-Fetch-Site=%q, Origin=%q, User-Agent=%q): %v",
c.Method(), c.Path(),
c.Get(fiber.HeaderSecFetchSite), c.Get(fiber.HeaderOrigin),
c.Get(fiber.HeaderUserAgent), err)
return c.Next()
}
Use ErrorHandler: reportOnly in the config and keep your existing csrf.New
registered after it, so the tokens still protect you while you watch. In a test
with both middleware in that order, a cross-site POST produced the report line
and was then rejected by csrf.New, exactly as before the change. The
middleware documentation does not describe calling c.Next() from
ErrorHandler as a feature. It works because the error handler runs inside the
middleware with the same context, which I verified against the v3.6.1 source,
so check it again when you upgrade.
During the overlap, csrf.New needs the same exemption for the identity
provider callback, through its own Next option, or logins break as soon as
both middleware run. And the report line contains the user agent and origin of
real users. Depending on where your logs go, that may count as personal data, so
set a retention period.
The report-only log has one blind spot. A request that carries neither
Sec-Fetch-Site nor Origin passes the check without ever reaching
ErrorHandler, so it never shows up there. That is exactly the group that loses
protection when the tokens go: very old browsers that send no Origin on a form
POST. Count them separately with a small middleware registered next to the
check:
// writesWithoutOrigin logs unsafe requests without Sec-Fetch-Site and Origin.
// The cross-origin check allows them without calling ErrorHandler.
func writesWithoutOrigin(c fiber.Ctx) error {
switch c.Method() {
case fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions:
return c.Next()
}
site := c.Get(fiber.HeaderSecFetchSite)
if site == "" && c.Get(fiber.HeaderOrigin) == "" {
log.Printf("csrf audit: %s %s without Sec-Fetch-Site or Origin "+
"(User-Agent=%q)",
c.Method(), c.Path(), c.Get(fiber.HeaderUserAgent))
}
return c.Next()
}
This log also contains your curl scripts, mobile apps and webhook senders,
which is fine, since they never had a browser session to abuse. What matters are
entries whose user agent is a browser. If you find some, those users depend on
the tokens, and removing csrf.New would leave their requests unchecked.
After a week of traffic both logs tell you what to do. Entries with an origin
you recognize belong in TrustedOrigins or in an exemption. Entries from
unknown origins are either attacks, which the tokens still stop, or clients you
need to talk to. When the report-only log is quiet and the audit log shows no
browsers, switch to the enforcing handler.
Selecting the handler with an environment variable at startup makes that switch,
and switching back, a configuration change, but only as long as csrf.New and
the token plumbing are still in place. Run the enforcing check together with the
tokens for a while. Once you remove csrf.New and the hidden fields, flipping
back to reportOnly would leave unsafe cross-origin requests with no CSRF
defense at all, because reportOnly lets everything through. From that point
on, a rollback means redeploying the token version, so keep that commit easy to
find.
Pin the Policy Down in a Testโ
Header-based checks are easy to break with an innocent refactoring, for example
by renaming a route. A table test against app.Test documents the policy and
catches that. Each row sets the headers a browser would send for one kind of
client:
package main
import (
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v3"
)
func TestCrossOriginProtection(t *testing.T) {
app := newApp()
const note = `{"text":"a"}`
tests := []struct {
name string
path, body string
fetchSite string
origin string
wantStatus int
}{
{"curl or another server", "/api/notes", note,
"", "", 201},
{"own page", "/api/notes", note,
"same-origin", "https://notes.example.com", 201},
{"trusted dashboard", "/api/notes", note,
"same-site", "https://dashboard.example.com", 201},
{"sibling subdomain", "/api/notes", note,
"same-site", "https://blog.example.com", 403},
{"attacker form", "/notes", "text=a",
"cross-site", "https://evil.example", 403},
{"cross-site without Origin", "/notes", "text=a",
"cross-site", "", 403},
{"typed URL or bookmark", "/notes", "text=a",
"none", "", 303},
{"identity provider", "/auth/callback", "state=s&code=c",
"cross-site", "https://id.example.org", 303},
{"identity provider, trailing slash", "/auth/callback/",
"state=s&code=c", "cross-site", "null", 303},
{"old browser, same host", "/notes", "text=a",
"", "http://example.com", 303},
{"old browser, other host", "/notes", "text=a",
"", "http://evil.example", 403},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(fiber.MethodPost, tt.path,
strings.NewReader(tt.body))
contentType := fiber.MIMEApplicationForm
if tt.body == note {
contentType = fiber.MIMEApplicationJSON
}
req.Header.Set(fiber.HeaderContentType, contentType)
if tt.fetchSite != "" {
req.Header.Set("Sec-Fetch-Site", tt.fetchSite)
}
if tt.origin != "" {
req.Header.Set("Origin", tt.origin)
}
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
}
})
}
}
httptest.NewRequest sets the Host header to example.com. The two "old
browser" rows rely on that to exercise the fallback for browsers that send
Origin but no Sec-Fetch-Site. All eleven cases pass against v3.6.1. The
table only covers POST because the service has no other unsafe routes; if
yours has PUT, PATCH or DELETE handlers, add a cross-site row for each
method. When you add a frontend or an exempt route later, add its row first and
watch it fail.
Where Tokens Still Winโ
For most new applications on HTTPS, the tokenless check is the better default.
It is not a strict upgrade, though, and in these situations I would keep
csrf.New or think hard first.
Old browsers come first. A browser without Sec-Fetch-Site falls back to the
Origin comparison, and a very old one that sends no Origin on a form POST
is let through unchecked, like any non-browser client. For a consumer site that
group is small. If you serve kiosks, embedded devices or enterprise desktops
that are years behind, the audit middleware from the rollout section tells you
how many such requests you actually get. The report-only log cannot, because
these requests never fail the check.
Plain HTTP is the second case. Browsers send no Fetch Metadata to a non-secure
origin other than localhost, so such a deployment depends on the
Origin-versus-Host comparison for every request. That comparison looks at
hosts, not schemes, so a page on http://notes.example.com passes a check on
https://notes.example.com. On HTTPS, current browsers send Sec-Fetch-Site
and never get there, which leaves only old ones exposed. HSTS closes that
remaining hole, because a browser that has seen the header no longer talks to
your host over plain HTTP. If you set it with the helmet
middleware, note that helmet sends
Strict-Transport-Security only when HSTSMaxAge is above zero, which is not
the default, and only when c.Secure() is true. Behind a TLS-terminating proxy
that requires TrustProxy and a TrustProxyConfig that trusts the proxy, so
Fiber believes its X-Forwarded-Proto header. Setting the header at the proxy
is often simpler.
Reverse proxies are the third. The Origin-versus-Host comparison reads the
Host header exactly as it arrives and ignores X-Forwarded-Host, even with
TrustProxy enabled. If your proxy rewrites Host to the upstream address, old
browsers are denied on your own forms. Current browsers are unaffected, since
they pass on Sec-Fetch-Site: same-origin. Either forward the original Host,
which most proxy setups do anyway, or add
your public origin to TrustedOrigins.
Last, some things no CSRF defense covers. State changes behind GET are never
checked, so a GET /notes/42/delete link is as dangerous as before. An XSS bug
on your own origin turns the attacker's script into a same-origin request.
Tokens do not help there either, since script on your page can read them.
An XSS bug on a trusted origin is where the two approaches differ. With the
tokenless check, a script on dashboard.example.com passes because the origin
match is the entire check. With csrf.New, that script still needs a valid
token, and it can only get one if your app hands it out to that origin, for
example through a CORS-readable endpoint that returns the token. The more
origins you trust, the more this difference matters.
Wrapping Upโ
csrf.NewCrossOriginProtection replaces a token that has to travel through
every template with two headers the browser already sends and attackers cannot
forge. On HTTPS with current browsers, it removes a whole class of plumbing
bugs, and unlike SameSite cookies it also stops sibling subdomains. What
remains is configuration: trusted origins that match your CORS config exactly,
an exemption with its own protection for every route that must accept cross-site
posts, and a report-only phase before you switch off the tokens.
Internal Referencesโ
- CSRF Middleware: Cross-Origin Protection Without Tokens
- Ctx: Endpoint
- CORS Middleware
- Helmet Middleware
- Security Middleware Stack
- The HTTP QUERY Method in Fiber v3
- Reverse Proxy Setup
- Fiber v3.6.0 release
- Fiber v3.6.1 release
- Add csrf.NewCrossOriginProtection (#4596)
- Fetch metadata (MDN)
- A modern approach to preventing CSRF in Go (Alex Edwards)
