- Docs
- HTTP APIs
- Sessions
Sessions
Keep server-side sessions in memory, Redis, or a database, and send an opaque secure cookie to the browser.
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.
Quick start
Section titled “Quick start”session: adapter: redis # production: shared across instances options: redis: addr: redis:6379SESSION_ADAPTER=redisSESSION_REDIS_ADDR=redis:6379config.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:
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)}curl -s -i -X POST localhost:8080/login | head -8HTTP/1.1 200 OKSet-Cookie: session_id=<opaque-id>; Path=/; HttpOnly; Secure; SameSite=LaxSession() 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.
Set the session cookie
Section titled “Set the session cookie”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.
Defend cookie sessions against CSRF
Section titled “Defend cookie sessions against CSRF”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:
- Keep the
Laxdefault; choosehttp.SameSiteStrictModefor high-risk apps (it also blocks top-level login links, so weigh the UX cost). - Set
Prefixtocookie.PrefixHost(__Host-) for single-host apps: the browser then refuses the cookie unless it isSecure,Path=/, and host-only. - Never change state on
GET. CSRF needs an ambient-credential state change; aGETthat only reads has nothing to steal. - Verify
Origin(falling back toReferer) on every state-changing handler against an explicit allowlist, and answer403on missing or untrusted values. Same-origin browser requests always send one of the two; a cross-site form cannot forgeOrigin. - When
SameSite=Noneis 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 withsubtle.ConstantTimeCompare. There is deliberately no framework CSRF middleware today — the check is ~20 lines your app owns, not a speculative battery. - For pure APIs, prefer
Authorization-header tokens (authjwt/oidc) over cookies: no ambient credentials means no CSRF surface at all.
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}Use the store API
Section titled “Use the store API”| 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.
Production checklist
Section titled “Production checklist”- Run more than one instance only on
redis. Memory sessions pin users to one process. - Stores are
Close()-shaped and close withc.Close(ctx). Shared redis clients close once. - Never log
Session.Datawhole. Log the ID and chosen fields. - Pass
r.Context()intoCreate,Get, andSave, and set explicit TTLs (short for sensitive flows).
Troubleshooting
Section titled “Troubleshooting”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.