After a user has logged in, the browser holds an access token and a refresh token. The question is: where should they be stored? The answer from a developer with no security experience is "in localStorage, it's convenient there." The correct answer is "only in HttpOnly cookies, otherwise any script on the page can steal them."
Let's look at why, and how this is implemented in Go.
Why localStorage is a bad idea
localStorage and sessionStorage are accessible through JavaScript: localStorage.getItem("token") works from any script on the page. This means that if the page has an XSS vulnerability (for example, through an included npm dependency or an external analytics widget), an attacker gets the token in a single line of code.
Cookies with the HttpOnly attribute are not accessible through document.cookie at all — the browser sends them in the request header automatically, but JavaScript can't reach the value.
Hence the rule: tokens in browser applications are stored only in HttpOnly cookies. The token never appears in the response body.
Three mandatory cookie attributes
When the server issues a token via http.SetCookie, three attributes are needed:
| Attribute | Why |
|---|---|
HttpOnly: true | JavaScript can't read the cookie. Protection against XSS. |
Secure: true | The cookie is sent only over HTTPS. Protection against network interception. |
SameSite: Lax or Strict | The cookie isn't sent to third-party sites in POST requests. Protection against CSRF. |
Without any one of the three, the protection is incomplete. Secure: true without HTTPS means the cookie isn't sent (which is good in production, but breaks local development without TLS).
How to issue a cookie after logging in via OIDC
A typical scheme in a BFF (Backend For Frontend): the browser sends the authorization code to the backend, the backend exchanges it for tokens with the provider (Keycloak, Google, and so on) and places them into cookies — the access token and the refresh token into different cookies with different settings.
// adapters/in/http/bff/auth_handler.go
func (h *AuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
tokens, err := h.oidc.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
httperr.Write(w, r, &apperr.AuthError{Reason: "code exchange failed"})
return
}
// access token — short-lived, needed across the whole site
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: tokens.AccessToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
MaxAge: int((15 * time.Minute).Seconds()),
})
// refresh token — lives longer, but available only on one endpoint
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: tokens.RefreshToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Path: "/auth/refresh",
MaxAge: int((7 * 24 * time.Hour).Seconds()),
})
http.Redirect(w, r, "/", http.StatusFound)
}
Note the Path: "/auth/refresh" on the refresh token. This means the browser will send the cookie with the refresh token only on requests to /auth/refresh — it won't be sent on any other endpoints (/orders, /profile, and so on). This limits the attack surface.
Refresh token rotation — why a refresh token can't be used twice
The access token lives for 15 minutes. When it expires, the browser makes a request to /auth/refresh with the refresh token, and the server issues a new pair of tokens. The question is: what to do with the old refresh token?
If it's left valid, an attacker who intercepts the refresh token once will be able to renew the session forever. That's why on each /auth/refresh the old refresh token is immediately invalidated and the user is issued a new one. This is called rotation.
An important point: if someone tries to use a refresh token that has already been used — that's a sign of compromise. The server must revoke the entire token chain and force the user to log in again.
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
rtCookie, err := r.Cookie("refresh_token")
if err != nil {
httperr.Write(w, r, &apperr.AuthError{Reason: "refresh token missing"})
return
}
tokens, err := h.oidc.Refresh(r.Context(), rtCookie.Value)
if err != nil {
// an exchange error with a valid token — possible compromise
h.tokenStore.RevokeFamily(r.Context(), rtCookie.Value)
http.SetCookie(w, clearCookie("refresh_token", "/auth/refresh"))
httperr.Write(w, r, &apperr.AuthError{Reason: "refresh failed"})
return
}
// new access token
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: tokens.AccessToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
MaxAge: int((15 * time.Minute).Seconds()),
})
// new refresh token — the provider has already marked the old one as used
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: tokens.RefreshToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Path: "/auth/refresh",
MaxAge: int((7 * 24 * time.Hour).Seconds()),
})
}
func clearCookie(name, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
HttpOnly: true,
Secure: true,
Path: path,
MaxAge: -1,
}
}
Reading the cookie in middleware
The BFF application reads the token from the session cookie in the middleware, verifies the JWT signature, and puts the user data into the request context.
// adapters/in/http/middleware/authn.go
func AuthN(v *security.JWTValidator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
// let public routes through — there will be no principal in the context
next.ServeHTTP(w, r)
return
}
principal, err := v.Validate(cookie.Value)
if err != nil {
httperr.Write(w, r, &apperr.AuthError{Reason: "invalid session"})
return
}
ctx := context.WithValue(r.Context(), principalKey, principal)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
In the chi router the middleware is attached to all routes, and protected groups additionally check roles:
r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator))
r.Get("/auth/callback", authHandler.Callback)
r.Post("/auth/refresh", authHandler.Refresh)
r.Post("/auth/logout", authHandler.Logout)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles("customer"))
r.Post("/orders", orderHandler.CreateOrder)
r.Get("/orders/{id}", orderHandler.GetOrder)
})
Logging out — explicitly clearing the cookies
On logout you need to do two things: revoke the refresh token with the provider and delete both cookies in the browser.
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if rt, err := r.Cookie("refresh_token"); err == nil {
_ = h.oidc.Revoke(r.Context(), rt.Value)
}
http.SetCookie(w, clearCookie("session", "/"))
http.SetCookie(w, clearCookie("refresh_token", "/auth/refresh"))
http.Redirect(w, r, "/", http.StatusFound)
}
MaxAge: -1 is a directive to the browser to delete the cookie immediately. Simply redirecting the user without clearing the cookies is a typical mistake: the old token is still alive until MaxAge expires.
The BFF as an intermediary to internal services
The BFF receives a request from the browser with a cookie, validates the token, and then calls internal services already with Authorization: Bearer in the header. Important: for requests to internal services the BFF uses its own Client Credentials — the user's token is not proxied there directly.
func (c *OrderClient) CreateOrder(ctx context.Context, cmd CreateOrderRequest) (OrderID, error) {
body, _ := json.Marshal(cmd)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/orders", bytes.NewReader(body))
tok, err := c.tokenSrc.Token() // Client Credentials, not the user's token
if err != nil {
return "", &apperr.GatewayError{System: "order-svc", Op: "token", Err: err}
}
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
// ...
}
Common mistakes
Token in the response body. If the server returns {"access_token": "..."} in JSON — browser JavaScript will get it and may save it in localStorage. The right way: the token is issued only through http.SetCookie.
Cookie without HttpOnly. JavaScript will be able to read document.cookie — the main protection against XSS is lost.
Refresh cookie on Path: "/". Then the refresh token flies on every request to the API — even though it's needed only to renew the session. Limit it to Path: "/auth/refresh".
Refresh without rotation. A single refresh token is used many times. If it leaks once — an attacker renews the session forever, and you don't know about it.
No MaxAge (session cookie). Without MaxAge the cookie lives until the browser is closed — on the next opening it disappears, but its lifetime isn't explicitly controlled. Always set MaxAge.
In short
- You can't store tokens in
localStorage: any JavaScript on the page can read them through XSS. - The right way is
http.SetCookiewithHttpOnly: true,Secure: true,SameSite. - The access token and the refresh token go into different cookies: the refresh is limited by
Path: "/auth/refresh". - On each
/auth/refreshthe old refresh token is invalidated and a new one is issued (rotation). - An attempt to use an already-used refresh token is a sign of compromise: we revoke the entire chain and require a fresh login.
- On logout we revoke the refresh token with the provider and clear both cookies via
MaxAge: -1. - The BFF calls internal services on its own behalf (Client Credentials), without forwarding the user's token.
What to read next
- JWT token validation in Go — how the BFF and internal services verify the token signature.
- Where the check goes — the gateway reads the cookie and converts it to a Bearer; the internal service doesn't see the cookie.
- Service-to-service authentication — cookies aren't used between services; only Client Credentials or mTLS.
- RBAC: role checks — how roles are extracted from the JWT and checked in middleware.