# 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

```yaml
session:
  adapter: redis    # production: shared across instances
  options:
    redis:
      addr: redis:6379
```
```sh
SESSION_ADAPTER=redis
SESSION_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](https://zenta-dev.github.io/zever/reference/config/#session).

Create, save, and read a session:

```go title="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)
}
```

```sh
curl -s -i -X POST localhost:8080/login | head -8
```

```text
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.

## 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

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.

```go title="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
}
```

## 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

- 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).

## Troubleshooting

<details>
<summary>Login works locally but drops in production</summary>

Two instances run `memory` sessions. Set `session.adapter: redis` with a shared `addr`.
</details>

<details>
<summary>Cookies are missing on local HTTP</summary>

`Secure` defaults to true and browsers drop it over HTTP. Set an explicit `false` for dev only.
</details>

<details>
<summary>`Get` returns `ErrNotFound` right after `Create`</summary>

`Save` was skipped, or the cookie holds the wrong ID. `Save` after mutating `Data`, and set the cookie from `sess.ID`.
</details>

## Related

- [Redis](https://zenta-dev.github.io/zever/guides/data/redis/): Shared versus memory, and redis options.
- [Codec](https://zenta-dev.github.io/zever/guides/http/codec/): How typed values become stored bytes.
- [Errors](https://zenta-dev.github.io/zever/guides/http/errors/): ErrNotFound branching with errors.Is.
