via

You are reading the docs for v0.8. Read the latest (v0.10)

Sessions & security

An action is an HTTP POST that any page on the web can aim at your server, so via treats the origin, the session and the tab as three separate checks. By default the tab check and the cookie flags are on and the origin check is off, which is right for localhost and wrong for production. Body and stream caps are router-wide With* options, and the CSP is derived from declared assets, never from a request.

On this page

#Origin checks and CSRF

router.go
func newRouter() *via.Router {
	return via.NewRouter(
		via.WithTrustedOrigin("https://example.com"),
		via.WithSecureCookies(), // TLS ends at the proxy, so req.TLS is nil here
		via.WithSessionKey([]byte(os.Getenv("SESSION_KEY"))),
		via.WithSessionTTL(8*time.Hour),
	)
}

Every action POST carries the tab's id in its body, a value same-origin script sets and the browser never attaches on its own. It is the CSRF token: an action on a live tab runs only with that tab's id, and only under the session the tab connected with. A request carrying another session's cookie answers 403 session mismatch, even with the right id.

via.WithTrustedOrigin adds the origin check in front of every action, in this order: an Origin on the allowlist passes; otherwise a Sec-Fetch-Site header must be same-origin or none; otherwise the Origin host must equal the request's Host. A request with neither header fails. Behind a proxy, list the public origin: the allowlist is checked first, so a rewritten Host does not matter.

shell
# without WithTrustedOrigin: the cross-origin sign-in runs
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Origin: https://evil.example' \
    -F name=mallory -F _viatab= https://example.com/security/_via/a/1/<id>
303
# with WithTrustedOrigin("https://example.com")
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Origin: https://evil.example' \
    -F name=mallory -F _viatab= https://example.com/security/_via/a/1/<id>
403
PropertyValueChange it with
HttpOnlyAlways.Nothing.
SameSiteLax.Nothing.
SecureOnly when the request arrived over TLS (req.TLS is set).via.WithSecureCookies behind a TLS-terminating proxy, where req.TLS is always nil.
Namevia_sessionvia.WithSessionCookieName: give each app its own when two share a host.
Lifetime24h idle, sliding on the server. The cookie's Max-Age is the TTL, set when the cookie is issued or rotated and not refreshed by use.via.WithSessionTTL
SignatureHMAC-SHA256 over the id. A key shorter than 16 bytes panics at startup.via.WithSessionKey, or the VIA_SESSION_KEY environment variable.

With no key set, via generates one per process and warns on the first session: cookies then die on every restart and are invalid on a second process. The cookie holds only a signed id, never data.

#Sessions and Rotate

#Sign in, sign out

via.Ctx.Session returns the browser's session. via.Session.Put stores one JSON value and issues the cookie on first write; via.Session.Get reads it back as a type. Both handlers call via.Session.Rotate, which moves the data to a fresh cookie id and deletes the old one, so an id an attacker planted before the login, or copied during it, resolves to nothing afterwards.

  • cookie id: none
  • retired by the last Rotate: none
  • Session.ID(): none

Try this

  • Sign in, sign out, sign in again: each Rotate lists the id it retired, and Session.ID() never changes.
  • Sign out and read the Wire pane: one POST, answered with a patch showing the next id. Its Set-Cookie is hidden from page script; the sign-in response below shows one.
Wire

Use the demo to see its requests and patches here.

Source
package demos

import (
	"strings"

	"github.com/go-via/via"
	"github.com/go-via/via/h"
	"github.com/go-via/via/on"
)

// SessionUser is the session's one value. Cookie and Retired exist only so
// the demo can show what each Rotate did: via never exposes the cookie id
// except as Rotate's return value.
type SessionUser struct {
	Name    string
	Cookie  string
	Retired string
}

// SecurityAuth signs in with a native form and signs out with an action,
// rotating the session id on both.
type SecurityAuth struct {
	user SessionUser
	sid  string
}

func (a *SecurityAuth) OnInit(ctx *via.Ctx) error { return a.load(ctx) }

// Logout rewrites the session OnInit already read.
func (a *SecurityAuth) OnReload(ctx *via.Ctx) error { return a.load(ctx) }

func (a *SecurityAuth) load(ctx *via.Ctx) error {
	a.user, _ = ctx.Session().Get[SessionUser]()
	a.sid = ctx.Session().ID()
	return nil
}

// Login is a native submit, so it can Redirect: the browser sends the new
// cookie only on the request after this one.
func (a *SecurityAuth) Login(ctx *via.Ctx) {
	name := strings.TrimSpace(ctx.Request().FormValue("name"))
	if name == "" {
		name = "anonymous"
	}
	if r := []rune(name); len(r) > 24 {
		name = string(r[:24])
	}
	// Rotate before the write: an id planted before the login never holds
	// the signed-in value.
	cookie := ctx.Session().Rotate()
	ctx.Session().Put(SessionUser{Name: name, Cookie: cookie, Retired: a.user.Cookie})
	mount, _, _ := strings.Cut(ctx.Request().URL.Path, "/_via/")
	ctx.Redirect(mount)
}

// Logout is an auth-state change too, so it rotates: a cookie captured while
// signed in resolves to nothing afterwards.
func (a *SecurityAuth) Logout(ctx *via.Ctx) {
	cookie := ctx.Session().Rotate()
	ctx.Session().Put(SessionUser{Cookie: cookie, Retired: a.user.Cookie})
}

// short prints enough of a 128-bit id to tell two apart and keeps the rest
// out of the DOM, where the cookie's HttpOnly flag would not protect it.
func short(id string) string {
	if id == "" {
		return "none"
	}
	return id[:min(8, len(id))] + "…"
}

func (a *SecurityAuth) ids() h.H {
	return h.Ul(h.Class("reflist"),
		h.Li(h.Str("cookie id: "), h.Code(h.Str(short(a.user.Cookie)))),
		h.Li(h.Str("retired by the last Rotate: "), h.Code(h.Str(short(a.user.Retired)))),
		h.Li(h.Str("Session.ID(): "), h.Code(h.Str(short(a.sid)))),
	)
}

func (a *SecurityAuth) View() h.H {
	if a.user.Name == "" {
		return h.Div(
			via.PostForm(a.Login,
				h.Div(h.Class("row"),
					h.Label(h.For("auth-name"), h.Str("name")),
					h.Input(h.ID("auth-name"), h.Name("name"), h.AutoComplete("off"), h.Placeholder("anything")),
					h.Button(h.Type("submit"), h.Str("sign in")),
				),
			),
			a.ids(),
		)
	}
	return h.Div(
		h.Div(h.Class("row"),
			h.Str("signed in as "),
			h.Strong(h.Str(a.user.Name)),
			h.Button(on.Click(a.Logout), h.Str("sign out")),
		),
		a.ids(),
	)
}
sign-in response
$ curl -si -H 'Origin: https://example.com' \
    -F name=ada -F _viatab= https://example.com/security/_via/a/1/<id>
HTTP/1.1 303 See Other
Location: /security
Set-Cookie: via_session=<signed id>; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=Lax
Date: Fri, 25 Sep 2026 16:10:27 GMT
Content-Length: 0
CallDoes
via.Session.PutStores the value, replacing the last one. Does not rotate.
via.Session.GetReads the value as T; false if none or if it no longer decodes.
via.Session.DeleteClears the value. The id and the cookie stay.
via.Session.RotateNew cookie id, same data, old id deleted. Returns the new id.
via.Session.EnsureIssues the cookie with no value, to key per-user state before there is any.
via.Session.IDA stable id that survives Rotate. Key your own data by it; it grants nothing.

Rotate and the first Put need a response to carry the cookie: call them in OnInit or an action, not in a Tick or Listen handler. Auth is a check in OnInit and a branch in View; composition and the hooks are on Compositions.

#Session stores

The default store is a map in the process, so a restart signs everyone out and a second process sees none of the first one's sessions. via.WithSessionStore takes a via.SessionStore: Load, Save and Delete of opaque bytes by id, with a TTL on Save. Pair it with a stable via.WithSessionKey: the key keeps the cookie valid, the store keeps the data behind it.

Two requests writing one session at once can lose a write against a plain store. Implement via.VersionedSessionStore (a conditional SaveIf) and via retries the merge instead. via.NewMemorySessionStore is the default and implements both. A store that fails a Load answers 503 rather than serving the request signed out; bound its calls with via.WithSessionStoreTimeout (default 5s).

#Content-Security-Policy

shell
$ curl -sI https://example.com/security \
    | grep -i '^content-security-policy' | tr ';' '\n'
Content-Security-Policy: default-src 'self'
 script-src 'self' 'unsafe-eval' 'sha256-qgomxTCf6EyY9wRYTe+McNJfTHmvsnuHqNwURb6wkX0=' 'sha256-w1BxZv52Hq0b9bKtw6PbfGN6HkmnMl6I3uT+H7Bwjsk=' 'sha256-2TX8Ens+wrX3VPIQX1+1OWZMcSu8dc4f2EwNYTMzJTs='
 style-src 'self'
 object-src 'none'
 base-uri 'self'
 form-action 'self'
 frame-ancestors 'self'
DirectiveWhy
default-src 'self'Nothing loads from another origin unless a directive below widens it.
script-src 'self' 'unsafe-eval'Plus a sha256 hash for each of via's inline scripts. No nonce, so nothing an injected tag could borrow.
style-src 'self'via emits no style element and no style attribute.
object-src 'none'Fixed.
base-uri 'self'Fixed.
form-action 'self'Fixed.
frame-ancestors 'self'Fixed.
img-srcAdded only when a preload names an image on another origin.

The policy is built once per mount from the router's via.WithHead assets plus the page's PageMeta().Assets: a script or stylesheet origin you declare joins its directive, an inline body joins by hash. It never varies per request, which is why Assets must be a constant of the type.

'unsafe-eval' is required: the bundled Datastar client compiles every data-* expression with the Function constructor. Without it every binding is dead in the browser while every server-side test passes.

#Request limits

OptionDefaultCaps, and the answer over it
via.WithMaxBody1 MiBAn action POST body, and how much of a form submit stays in memory. 413 request body too large.
via.WithMaxUpload8 MiBA PostForm submit's whole multipart body; the part past WithMaxBody spills to a temp file. 413 request body too large.
via.WithMaxSSEConn10000Open live streams across the Router. 503 stream capacity reached.
via.WithPinnedDeadline5sHow long an action waits for its tab's goroutine to pick it up. 503 stream busy.

The stream cap is router-wide, with no per-client share: one client can hold all of it. Set it to what the machine has memory for, since each stream holds a goroutine and a composition tree for the tab's life. A per-client limit is middleware.

#Middleware

middleware.go
func securityHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
		w.Header().Set("Strict-Transport-Security", "max-age=63072000")
		next.ServeHTTP(w, r)
	})
}

type statusWriter struct {
	http.ResponseWriter
	status int
}

func (s *statusWriter) WriteHeader(code int) {
	s.status = code
	s.ResponseWriter.WriteHeader(code)
}

// Unwrap lets via reach the real writer's Flush; without it every live
// page's stream connect answers 500.
func (s *statusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter }

func accessLog(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(sw, r)
		slog.Info("request", "method", r.Method, "path", r.URL.Path,
			"status", sw.status, "took", time.Since(start))
	})
}

func main() {
	app := newRouter() // *via.Router is an http.Handler
	http.ListenAndServe(":8080", accessLog(securityHeaders(app)))
}

via.Router is an http.Handler, so logging, rate limits, auth redirects and extra headers are ordinary net/http wrappers; via has no middleware API of its own. A wrapper sees page GETs, action POSTs under /_via/a/ and stream connects under /_via/sse alike. A wrapper that replaces the ResponseWriter must expose Unwrap() http.ResponseWriter, or via cannot flush and every stream connect answers 500.