Skip to content
Zever
Home

Sessions

Keep server-side sessions in memory, Redis, or a database, and send an opaque secure cookie to the browser.

4 min readEdit on GitHub

A session is a server-side row behind a cookie: the browser holds an opaque ID, the server holds the data. Use sessions for browser flows that need revocation (logout, password reset, privilege change), and stateless tokens (auth) for APIs and service-to-service calls.

session:
adapter: redis # production: shared across instances
options:
redis:
addr: redis:6379

config.Default() resolves memory (dev needs no server). Zero TTL means DefaultTTL (15 minutes); Options.Validate() rejects negative TTLs. The db adapter takes dsn and dedicated_pool (shares the container pool per DSN by default); redis takes redis.prefix plus the shared Redis connection keys. All options: Config reference.

Create, save, and read a session:

session.go
import (
"context"
"net/http"
"github.com/zenta-dev/zever/adapters/session/cookie"
"github.com/zenta-dev/zever/core/session"
)
// At startup: sessionmemory.Register(); c := container.New(config.Default()); store, _ := c.Session()
func login(ctx context.Context, store session.Store, w http.ResponseWriter) error {
sess, _ := store.Create(ctx, session.DefaultTTL)
sess.Data["user_id"] = "u_123"
_ = store.Save(ctx, sess)
cookie.Set(w, sess.ID, cookie.Options{})
return nil
}
func loadSession(ctx context.Context, store session.Store, r *http.Request) (session.Session, error) {
c, err := r.Cookie(cookie.DefaultName)
if err != nil {
return session.Session{}, err
}
return store.Get(ctx, c.Value)
}
Terminal window
curl -s -i -X POST localhost:8080/login | head -8
HTTP/1.1 200 OK
Set-Cookie: session_id=<opaque-id>; Path=/; HttpOnly; Secure; SameSite=Lax

Session() resolves lazily and caches. Get returns session.ErrNotFound for a missing or expired session, which are indistinguishable by design. Session.Data is a plain map[string]any; keep it to IDs and flags, because every request loads the row. A ttl <= 0 passed to Create means no expiry.

session.Store knows nothing about HTTP. cookie.New(sess.ID, opts) builds a plain *http.Cookie; cookie.Set(w, id, opts) writes it.

Attribute Default
Name, Path cookie.DefaultName (session_id) and cookie.DefaultPath (/); Options.Name and Options.Path override.
Domain Unset (host-only). A value widens the cookie to subdomains.
Prefix None. cookie.PrefixHost (__Host-) forces Secure, Path=/, no Domain; cookie.PrefixSecure (__Secure-) forces Secure.
Secure flags HttpOnly and Secure default to true (HTTPS only); SameSite defaults to Lax (use http.SameSiteStrictMode for high-risk apps).
MaxAge 0: a session cookie. Negative deletes it; positive sets Max-Age and Expires.

Secure and HttpOnly are *bool, so an explicit false (local HTTP dev) differs from unset. Rotate the session ID at privilege changes (login, password reset) to block fixation: Delete the old row, then Create a new one.

The browser attaches session_id to every request automatically, including a cross-site POST from an attacker’s form or fetch. SameSite=Lax (the default) stops most of it — modern browsers withhold the cookie on cross-site subrequests — but it does not cover everything: top-level GET navigations still carry the cookie, older browsers ignore SameSite entirely, a sibling subdomain can attack when Domain is widened, and any flow that needs SameSite=None opts out of the protection. Layer accordingly:

  1. Keep the Lax default; choose http.SameSiteStrictMode for high-risk apps (it also blocks top-level login links, so weigh the UX cost).
  2. Set Prefix to cookie.PrefixHost (__Host-) for single-host apps: the browser then refuses the cookie unless it is Secure, Path=/, and host-only.
  3. Never change state on GET. CSRF needs an ambient-credential state change; a GET that only reads has nothing to steal.
  4. Verify Origin (falling back to Referer) on every state-changing handler against an explicit allowlist, and answer 403 on missing or untrusted values. Same-origin browser requests always send one of the two; a cross-site form cannot forge Origin.
  5. When SameSite=None is required or subdomains are hostile, add a synchronizer token: mint a random per-session value, render it into forms (or expose it to your frontend over an authenticated endpoint), and require it back in a header or field, comparing with subtle.ConstantTimeCompare. There is deliberately no framework CSRF middleware today — the check is ~20 lines your app owns, not a speculative battery.
  6. For pure APIs, prefer Authorization-header tokens (auth jwt/oidc) over cookies: no ambient credentials means no CSRF surface at all.
csrf.go
import (
"crypto/subtle"
"net/http"
"strings"
)
// allowOrigin reports whether origin is this app's own site. Compare
// against an explicit allowlist, never against the request Host: the
// Host header is attacker-controlled on most deployments.
func allowOrigin(origin string) bool {
return origin == "https://app.example.com"
}
func requireSameOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" {
if !allowOrigin(origin) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
} else if referer := r.Header.Get("Referer"); referer != "" {
if !strings.HasPrefix(referer, "https://app.example.com/") {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
} else {
// No Origin and no Referer: a browser same-origin POST always
// sends one of them, so fail closed rather than assume safe.
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func checkToken(submitted, expected string) bool {
return subtle.ConstantTimeCompare([]byte(submitted), []byte(expected)) == 1
}
Method Purpose
Create(ctx, ttl) Mint a session; ttl <= 0 never expires.
Get(ctx, id) Load by ID; ErrNotFound on missing or expired.
Save(ctx, sess) Upsert the row and touch UpdatedAt.
Delete(ctx, id) Remove the row; idempotent (use for logout).

Helpers: session.NewSession(id, ttl), Session.Clone(), Session.Expired(now), session.Open(adapter, opts), and session.Register(adapter, factory). Use auth (jwt, oidc) for stateless tokens, session for revocable browser flows, and authz to verify via auth and decide via permission.

  • Run more than one instance only on redis. Memory sessions pin users to one process.
  • Stores are Close()-shaped and close with c.Close(ctx). Shared redis clients close once.
  • Never log Session.Data whole. Log the ID and chosen fields.
  • Pass r.Context() into Create, Get, and Save, and set explicit TTLs (short for sensitive flows).
Login works locally but drops in production

Two instances run memory sessions. Set session.adapter: redis with a shared addr.

Cookies are missing on local HTTP

Secure defaults to true and browsers drop it over HTTP. Set an explicit false for dev only.

Get returns ErrNotFound right after Create

Save was skipped, or the cookie holds the wrong ID. Save after mutating Data, and set the cookie from sess.ID.