Why SameSite=Lax Breaks Cross-Site POST Logins
Someone tidies up the cookie code, adds an explicit SameSite: http.SameSiteLaxMode, and users start bouncing back to the login page. Your logs say "state cookie missing". Only on the callback. Only for some identity providers.
The maddening part is what still works. Login works when you test it with curl. It worked in your browser on Tuesday.
A correction first, because this bug gets described as "the server pushing a cookie", which mixes two things up. HTTP/2 push is effectively dead in browsers, and a cookie is just a Set-Cookie header on whatever response you send. The real bug has nothing to do with push. It is about which requests the browser is willing to attach a cookie to.
The handler that worked by accident
Here is a stripped-down OAuth-style flow:
/loginstores a random state value in a cookie and sends the user to the identity provider.- The provider later sends the browser back to
/callback. - We compare the cookie against the state it echoes.
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
"net/url"
)
func newToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func login(w http.ResponseWriter, r *http.Request) {
state, err := newToken()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: state,
Path: "/callback",
MaxAge: 300,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode, // the "hardening" that breaks it
})
q := url.Values{
"client_id": {"example"},
"response_type": {"code"},
"response_mode": {"form_post"},
"state": {state},
}
http.Redirect(w, r, "https://idp.example/authorize?"+q.Encode(), http.StatusSeeOther)
}
The key line is response_mode=form_post. Instead of redirecting the browser back with ?code=...&state=... on the URL, the provider returns a tiny auto-submitting HTML form. The browser then makes a cross-site POST to your /callback.
SAML's POST binding behaves the same way, and so do a number of payment-provider return pages.
Lax cookies skip cross-site POSTs
A SameSite=Lax cookie is sent on same-site requests, and on cross-site requests only when they are top-level navigations using a safe method (GET, basically). A cross-site POST is neither, so the browser leaves the cookie off. Your handler sees no oauth_state and rightly refuses the login.
Why it broke "suddenly"
Actually, this bit is the reason the bug appears out of nowhere. Go's zero value for SameSite emits no attribute at all. I checked on Go 1.22:
SameSite: 0serialises as plains=v.SameSiteDefaultModealso serialises as plains=v.SameSiteLaxModegivess=v; SameSite=Lax.
Chromium treats a missing attribute as Lax, but it has had a temporary carve-out (often called Lax+POST). That lets a cookie without an explicit SameSite value ride on a top-level cross-site POST for the first couple of minutes after it was set.
That is precisely the window a login round trip lives in. Write the attribute explicitly and you opt out of the carve-out. The code got "more correct" and stopped working.
Other browsers do not necessarily match Chromium's defaults or its exceptions, and these rules have shifted over the years. Test in the browsers you support rather than trusting my summary.
Why curl and go test never catch it
curl has no idea what SameSite is. It will send the cookie jar back to any URL matching the domain and path, and your httptest suite does the same. The only place this fails is a real browser.
So the check belongs in browser devtools. In Chromium the Network panel marks the request's blocked cookies, and the reason reads as a SameSite problem. Look at the callback request specifically, not the page that follows it.
Fix 1: stop using a POST callback
If you control the client, use the authorisation code flow with PKCE and the default query response mode. The provider redirects the browser back with a GET, which is a top-level navigation with a safe method, so Lax cookies are sent and you can keep the strict setting.
This is the option I would pick, because you get to keep the tighter cookie.
Fix 2: loosen only the cookie that has to travel
When the provider insists on form_post, relax that one short-lived cookie and nothing else:
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: state,
Path: "/callback",
MaxAge: 300,
HttpOnly: true,
Secure: true, // browsers reject SameSite=None without Secure
SameSite: http.SameSiteNoneMode,
})
Two traps here:
- Go's
Cookie.Valid()will happily return nil forSameSite=NonewithoutSecure, but browsers will drop the cookie. The compiler and the type system will not save you. Nonemeans the cookie is attached to any cross-site request to that path, so keep the path narrow and the lifetime short.
Your state value is checked against the form field anyway; the cookie is only there to bind it to this browser.
The callback then verifies, clears the state cookie with matching attributes, and issues the real session cookie:
func callback(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("oauth_state")
if err != nil {
http.Error(w, "state cookie missing", http.StatusBadRequest)
return
}
got := r.PostFormValue("state")
if subtle.ConstantTimeCompare([]byte(c.Value), []byte(got)) != 1 {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{
Name: "oauth_state", Path: "/callback", MaxAge: -1,
Secure: true, SameSite: http.SameSiteNoneMode,
})
session, err := newToken()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// ... exchange the code, store session server-side ...
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: session,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
Use a 303, or you get logged out one hop later
Notice the http.StatusSeeOther at the end. The session cookie is stored fine when set on the response to a cross-site POST; the browser accepts Set-Cookie regardless. The trouble is the next request:
- A 303 turns it into a GET, which is a safe top-level navigation, so the Lax session cookie goes along.
- A 307 or 308 preserves the POST method, the redirected request is still treated as cross-site, and you will be logged out one hop after logging in.
Go's http.Redirect takes whatever code you give it, so pick the 303 on purpose.
The tidy version of all this: Lax for the session, None; Secure only for the one cookie that has to survive a cross-site POST, and a GET callback wherever the provider lets you have one.