DEV Community

BramwellVance7953
BramwellVance7953

Posted on

How to Diagnose Session Refresh Loops in Go (and Expired Login State)

Short answer: treat a refresh loop as a state-machine failure, then trace one request from the browser alert through cookie attributes, token clocks, refresh rotation, and the API's retry policy. For a forgot-password flow that must survive an audit, emit a correlation ID and a reason code before changing any provider or storage layer.

The page that wakes someone up usually says something unhelpful: “login expired,” followed by three or four calls to /session/refresh and a final 401. The user sees a sign-in screen; the on-call sees noise. In a migration off a managed authentication provider, that noise is especially expensive because two session implementations can disagree while both look healthy in isolation.

I start with the earliest signal, not the last 401. A refresh request that succeeds but is immediately followed by another refresh means the client did not accept the new state. A refresh that returns 401 once, then succeeds on a retry, points to a race or an expired cookie. Those are different incidents with different fixes. Keep them separate in the alert labels. During a provider migration, preserve the old and new reason codes in the same dashboard for at least one release; otherwise a renamed invalid_grant can look like a sudden improvement. Compare the timestamp emitted by the token issuer with the timestamp read by the session store, include the deployment region, and sample the browser's build number. That longer trace is what distinguishes a clock problem from a client that is writing a stale cookie after every successful response.

Stop retrying.

What should the first alert show?

An alert should identify the session subject without logging a token. Use a hash of the session ID, the request correlation ID, the endpoint, the result class, and the token age. The useful dimensions are boring: refresh_result=accepted|expired|replayed, cookie_present=true|false, and client_build. They let you compare browser behavior with API logs while keeping credentials out of the audit trail.

The threshold needs a capacity assumption. If the normal refresh rate is one call per active tab every 10 minutes, a sudden five calls in 30 seconds is a loop even when every response is a 200. I am not sure your traffic has that shape; measure it for a week before setting an SLO alert. A reasonable starting SLO is that 99.9% of refresh attempts either extend a valid session or return one explicit terminal reason, never an unbounded sequence of retries.

Here is a small Go handler that records the decision without exposing the refresh token. It assumes the application has already authenticated the request and has a server-side session store.

package auth

import (
    "crypto/sha256"
    "encoding/hex"
    "log/slog"
    "net/http"
    "time"
)

type RefreshDecision struct {
    Accepted  bool
    Reason    string
    SessionID string
    TokenAge  time.Duration
}

func hashID(id string) string {
    sum := sha256.Sum256([]byte(id))
    return hex.EncodeToString(sum[:8])
}

func Refresh(w http.ResponseWriter, r *http.Request, decide func(*http.Request) RefreshDecision) {
    d := decide(r)
    slog.InfoContext(r.Context(), "session refresh",
        "refresh_result", d.Reason,
        "session_hash", hashID(d.SessionID),
        "token_age_ms", d.TokenAge.Milliseconds(),
    )

    if !d.Accepted {
        http.Error(w, d.Reason, http.StatusUnauthorized)
        return
    }
    w.Header().Set("Cache-Control", "no-store")
    w.WriteHeader(http.StatusNoContent)
}

func init() { slog.SetDefault(slog.New(slog.NewTextHandler(log.Writer(), nil))) }
Enter fullscreen mode Exit fullscreen mode

The important part is not the logger. It is the single decision point. If middleware, a background refresh task, and a page loader each decide independently whether a session is expired, they can manufacture a loop without any broken cryptography.

How do you diagnose session refresh loops and expired login state?

Work backwards from one correlation ID. First, confirm the browser sent the cookie on the refresh request. Check Secure, HttpOnly, SameSite, the Domain, and the Path; a cookie that is valid for the application page can still be absent from the refresh route. Next, compare the server's expiry timestamp with a trusted clock. A five-minute clock skew can make a freshly issued token appear expired, while a client clock should never be allowed to decide server-side validity.

Then inspect refresh rotation. A rotating token is single-use: request A consumes the old value and returns a new one; request B, started by another tab or a retry, must receive a clear replayed result. The client must replace the stored value atomically before it retries. If it retries with the old value, it will produce a loop that looks like an intermittent provider failure.

The most revealing test is a two-tab replay with throttled network conditions. Record the sequence, not just the final status:

type Event struct {
    CorrelationID string
    At            time.Time
    Result        string
    HTTPStatus    int
}

func classify(events []Event) string {
    if len(events) >= 3 && events[len(events)-1].Result == "replayed" {
        return "client_retry_after_rotation"
    }
    if len(events) > 0 && events[len(events)-1].Result == "expired" {
        return "clock_or_absolute_expiry"
    }
    return "inspect_cookie_and_transport"
}
Enter fullscreen mode Exit fullscreen mode

A useful dashboard groups by client_build, route, and result reason. Do not group only by HTTP status; 401 hides clock skew, missing cookies, revoked sessions, and replayed refresh tokens. Add a panel for refresh attempts per authenticated user, because a healthy-looking 200 rate can still consume connection capacity when a client spins.

Instrument the boundary before changing the provider

The migration decision is easier when the application owns a narrow interface. Keep token issuance, session persistence, and password-reset audit events behind three calls, and record their latency and terminal reason. The interface can be backed by a managed service during the migration and by a self-hosted store later; the diagnostic signals should not change.

Choice What you gain What you carry
Managed session service Less patching and a smaller on-call surface Provider-specific expiry and rotation semantics
Self-hosted session store Direct control of retention, keys, and audit exports Capacity planning, key rotation, and incident response
Standards-based adapter A stable application contract during migration An adapter to test against every backend's edge cases

The catch is operational ownership. A self-hosted option is not suitable when nobody can rotate signing keys or rehearse a restore; stick with a managed service until those controls are staffed. A managed option is a poor fit when the audit requires raw event retention or a network boundary it cannot provide. The decision is about failure modes and on-call load, not a claim that one backend is universally safer.

For the forgot-password path, write an audit event when the reset request is accepted, when a token is redeemed, and when a session is revoked. Store a reason code such as expired, already_used, or revoked, never the token itself. OWASP recommends generic user-facing responses for authentication and recovery flows so account existence is not disclosed; the event stream can still be precise for operators.

Validate the fix against the SLO

After changing cookie attributes or rotation logic, replay the original trace in staging with two tabs, a suspended laptop clock, a cold browser profile, and a network that drops one response. The pass condition is finite: one refresh request per trigger, one replacement value, and one terminal event when the session cannot be renewed. A client that keeps trying after expired is a product bug even if the API is correct.

Keep the alert quiet enough to be believed. A false positive pages the same person who is trying to migrate the provider, and it trains the team to ignore the next real expiry storm. I ran into this pattern while reviewing a 30-second threshold: a single mobile reconnect looked like a loop, so the threshold was changed to count unique session hashes and client builds instead of raw requests. That distinction made the SLO useful without hiding a real retry storm.

Your mileage may vary. The exact cookie and token settings depend on the browser topology and the reset-token policy, so document those assumptions beside the runbook and test them whenever the client build changes.

References

Further reading

Top comments (0)