Skip to content

Sessions

wiki edited this page Sep 4, 2026 · 1 revision

Sessions

Server-side sessions for a Backend-For-Frontend: the browser holds an opaque identifier, the server holds everything else.

store := myRedisStore{}
validator := security.NewSessionStoreValidator(store,
	security.WithIdleTimeout(30*time.Minute),
	security.WithAbsoluteTimeout(12*time.Hour),
)

scheme := security.NewSessionCookieScheme("session", "session_id", validator).
	WithCookieOptions(security.CookieOptions{
		MaxAge:   int((12 * time.Hour).Seconds()),
		SameSite: http.SameSiteLaxMode,
		HttpOnly: true,
	})

Two timeouts, and why both

Default Refreshed Answers
Idle 30 min on every successful validation how long may a session sit unused?
Absolute 12 h never — set at issue how long may a session live at all?

Neither alone is adequate:

  • Idle alone bounds nothing for an attacker with a valid cookie. A script touching the session once a minute keeps it alive indefinitely.
  • Absolute alone logs active users out mid-task.

Both together is the only combination that is neither. WithIdleTimeout(0) or WithAbsoluteTimeout(0) disables that bound — a deliberate weakening in both directions.

Login: rotate, do not just issue

func login(ctx rxroute.Context) {
	user, err := authenticate(ctx.Request())
	if err != nil {
		rextension.WriteProblem(ctx.ResponseWriter(), ctx.Request(),
			401, rextension.ProblemUnauthorized, "credentials were not accepted")
		return
	}

	// ← this one, not IssueSession
	if _, err := scheme.IssueSessionForRequest(ctx, ctx.ResponseWriter(), ctx.Request(), user); err != nil {
		rextension.WriteProblem(ctx.ResponseWriter(), ctx.Request(),
			500, rextension.ProblemInternal, "could not complete sign-in")
		return
	}
	_ = ctx.JSON(200, map[string]string{"status": "ok"})
}

IssueSessionForRequest is the session-fixation defence.

The attack: an attacker obtains a session identifier from the application before logging in — trivially, by visiting it — plants it in the victim's browser, and waits. If the identifier survives the victim's authentication, the attacker now holds a cookie for the victim's authenticated session. Nothing about it requires reading the victim's traffic.

Rotating deletes the planted identifier at the exact moment it would have become valuable. The old session is revoked whether or not this application issued it, and it falls back to a plain issue when the request carries no cookie — so it is safe to call unconditionally.

The absolute deadline is carried over, not reset. Resetting it would let an attacker with a valid session extend its lifetime indefinitely by re-authenticating, which is the bound rotation exists to preserve.

Logout

func logout(ctx rxroute.Context) {
	if err := scheme.RevokeSession(ctx, ctx.ResponseWriter(), ctx.Request()); err != nil {
		log.WithError(err).Warn("revoke failed")
	}
	ctx.ResponseWriter().WriteHeader(http.StatusNoContent)
}

Deletes the session server-side and clears the cookie. A no-op when the cookie is absent.

Cookie options

type CookieOptions struct {
	MaxAge                 int           // seconds; 0 = session cookie; negative deletes
	Path                   string        // "/" when empty
	Domain                 string
	AllowInsecureTransport bool          // ⚠ leave false
	HttpOnly               bool          // default true
	SameSite               http.SameSite // default Lax
}

There is no Secure field, on purpose. The zero value of AllowInsecureTransport produces Secure, which is the point: a session cookie is a bearer credential, and a Secure-less cookie is transmitted in cleartext on any plain-HTTP request to the domain — including one an attacker induces, and including subdomains.

It replaced a Secure bool that defaulted to false, making the unsafe choice the one you got by not thinking about it. Inverting the field rather than adding a *bool means the unsafe option has to be named, and means existing Secure: true code fails to compile rather than silently inverting.

Set AllowInsecureTransport: true for http://localhost development, and nowhere else.

SameSite=Lax is a large mitigation but not sufficient on its own — see CSRF for the four cases it misses.

The store

type SessionStore interface {
	Get(ctx context.Context, sessionID string) (principal interface{}, err error)
	Set(ctx context.Context, sessionID string, principal interface{}, expiresAt time.Time) error
	Touch(ctx context.Context, sessionID string, expiresAt time.Time) error
	Delete(ctx context.Context, sessionID string) error
}

Expiry is the store's job. Every method that can observe an expiry takes or enforces one, and the store is authoritative — not a stylistic choice: only the store can actually evict, so only the store can decide a session is gone. A validator that checked expiry itself would be checking a copy, correct until the two disagreed, at which point the session is alive in storage and dead in the application, or the reverse.

A Redis or Memcached implementation gets this for free by passing expiresAt through as a TTL.

func (s *redisStore) Set(ctx context.Context, id string, p interface{}, expiresAt time.Time) error {
	b, err := json.Marshal(p)
	if err != nil {
		return err
	}
	return s.c.Set(ctx, "sess:"+id, b, time.Until(expiresAt)).Err()
}

func (s *redisStore) Touch(ctx context.Context, id string, expiresAt time.Time) error {
	ok, err := s.c.Expire(ctx, "sess:"+id, time.Until(expiresAt)).Result()
	if err != nil {
		return err
	}
	if !ok {
		return security.ErrSessionNotFound
	}
	return nil
}

Touch must not extend past the absolute deadline. An implementation that does turns the absolute bound into no bound at all, which is the whole point of having two. SessionStoreValidator passes the earlier of the two deadlines, so honouring expiresAt is enough.

Breaking change. Set gained expiresAt and the interface gained Touch. An implementation that ignores expiresAt still compiles and silently never expires a session — so the parameter is worth honouring rather than accepting.

Session errors

var (
	ErrSessionExpired  = errors.New("security: session has expired")
	ErrSessionNotFound = errors.New("security: session not found")
)

Distinct on purpose, so a caller can tell "log in again" from "this session ID was never valid". The second is worth alerting on — it means a client is presenting a forged or replayed identifier.

Identifiers

SessionStoreValidator generates a 64-hex-character (256-bit) cryptographically random identifier. If you write your own validator, match that: anything derived from a counter, a timestamp, or a user id is guessable.

A custom validator

type SessionValidator interface {
	ValidateSession(ctx context.Context, sessionID string) (principal interface{}, err error)
	IssueSession(ctx context.Context, principal interface{}) (sessionID string, err error)
	RevokeSession(ctx context.Context, sessionID string) error
}

Also implement SessionRotator — it is optional, and without it IssueSessionForRequest cannot rotate, leaving the application exposed to session fixation:

type SessionRotator interface {
	RotateSession(ctx context.Context, oldID string) (newID string, err error)
}

Clone this wiki locally