-
Notifications
You must be signed in to change notification settings - Fork 0
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,
})| 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.
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.
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.
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
Securefield, on purpose. The zero value ofAllowInsecureTransportproducesSecure, which is the point: a session cookie is a bearer credential, and aSecure-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 boolthat defaulted to false, making the unsafe choice the one you got by not thinking about it. Inverting the field rather than adding a*boolmeans the unsafe option has to be named, and means existingSecure: truecode 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.
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.
SetgainedexpiresAtand the interface gainedTouch. An implementation that ignoresexpiresAtstill compiles and silently never expires a session — so the parameter is worth honouring rather than accepting.
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.
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.
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)
}rextension-security — authentication, authorization and CSRF for Rex · MIT · © 2026 Kryovyx