Blog / Go

  • go
  • net-http
  • cookies
  • samesite
  • oauth
  • debugging

Go's SameSite=Lax Cookie Breaks Cross-Site POST Logins

First, a correction to the premise, because the topic I was handed mixes two things up. There is no such thing as "server pushing a cookie" in a way that matters here. 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 that produces "my login broke after I added SameSite=Lax" has nothing to do with push. It is about which requests the browser is willing to attach a cookie to.

The symptom looks like this. Login works when you test it with curl. It works in your browser on Tuesday. Then someone tidies up the cookie code, adds an explicit SameSite: http.SameSiteLaxMode, and users bounce back to the login page with "state cookie missing" in your logs. Only on the callback. Only for some identity providers.

The handler that worked by accident

Here is a stripped-down OAuth-style flow. /login stores a random state value in a cookie and sends the user to the identity provider. The provider later sends the browser back to /callback, and 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.

What Lax actually means

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.

Actually, this bit is the reason the bug appears "suddenly". Go's zero value for SameSite emits no attribute at all. I checked on Go 1.22: SameSite: 0 and SameSiteDefaultMode both serialise as plain s=v, while SameSiteLaxMode gives s=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. 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 for SameSite=None without Secure, but browsers will drop the cookie, so the compiler and the type system will not save you. And None means 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)
}

The redirect status matters too

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.