Login
Demo: Use any username with password secretAfter login, the server will push a welcome message via WebSocket.
Most LiveTemplate code is reactive — actions arrive over a WebSocket, state mutates, the framework patches the DOM. Login flow is the one place that doesn't fit that mould: the browser hasn't authenticated yet, so there's no WebSocket to ride. Either you reach for a separate auth middleware (and lose framework-native flash messages, validation tags, and lifecycle hooks), or you let LiveTemplate handle it the way it handles everything else — as a controller action.
This recipe shows the second path. A login form posts to the same handler that renders the dashboard. The controller's Login method validates the credentials, sets an HttpOnly cookie via ctx.SetCookie, and 303-redirects. When the browser follows the redirect and the WebSocket connects, an OnConnect lifecycle hook spawns a goroutine that pushes a welcome message back to the client via session.TriggerAction. Logout mirrors the login shape — a POST that deletes the cookie and redirects.
Three pieces of the framework that are easy to miss until you need them: cookies as first-class context primitives, forms that opt out of WebSocket interception for the auth round-trip, and server-initiated state updates for everything after the page loads.
Try it right here. Any username; the password is secret. After the dashboard loads, watch for the welcome message — it's pushed from the server ~500ms after the WebSocket connects, no client poll involved:
The state holds only what the template needs to render either the login form or the dashboard. lvt:"persist" tags survive WebSocket reconnects via the framework's client-side state checksum:
// AuthController holds shared state and dependencies.
// This is a singleton that persists across sessions.
type AuthController struct {
// For server-initiated updates (per-session)
sessions map[string]livetemplate.Session
mu sync.Mutex
}
// AuthState is pure data, cloned per session.
// Contains only serializable fields for the auth UI.
type AuthState struct {
Username string `lvt:"persist"`
IsLoggedIn bool `lvt:"persist"`
ServerMessage string
LoginTime time.Time `lvt:"persist"`
}
ServerMessage is intentionally not persisted — it's set every time the WebSocket connects and re-derived on reconnect, so persisting it would mean stale welcome text.
The login form in the template opts out of WebSocket interception with lvt-form:no-intercept. When the user clicks Login, the browser submits a standard HTTP POST to the page URL with Accept: text/html, and the framework routes the request to the controller's Login method:
// Login handles the "login" action.
func (c *AuthController) Login(state AuthState, ctx *livetemplate.Context) (AuthState, error) {
username := ctx.GetString("username")
password := ctx.GetString("password")
// Field-level validation
if username == "" {
return state, livetemplate.NewFieldError("username", fmt.Errorf("username is required"))
}
if password == "" {
return state, livetemplate.NewFieldError("password", fmt.Errorf("password is required"))
}
// Demo: accept any username with password "secret"
if password != "secret" {
ctx.SetFlash("error", "Invalid credentials")
return state, nil
}
state.Username = username
state.IsLoggedIn = true
state.LoginTime = time.Now()
state.ServerMessage = "" // Will be set when WebSocket connects
// Set HttpOnly session cookie
err := ctx.SetCookie(&http.Cookie{
Name: "session_token",
Value: fmt.Sprintf("session_%s_%d", username, time.Now().Unix()),
Path: "/",
HttpOnly: true,
Secure: false, // Set to true in production with HTTPS
SameSite: http.SameSiteStrictMode,
MaxAge: 3600, // 1 hour
})
if err != nil {
return state, fmt.Errorf("failed to set cookie: %w", err)
}
// Redirect to dashboard (page will load, then WebSocket connects)
return state, ctx.Redirect("/", http.StatusSeeOther)
}
Three framework primitives carry the auth weight here:
livetemplate.NewFieldError("field", err) — surfaces validation errors keyed to a form input. The template binds them via {{.lvt.ErrorTag "username"}} and {{.lvt.AriaInvalid "username"}} so the rendered form keeps the user's filled-in fields and decorates the bad one with aria-invalid plus an error message.
ctx.SetCookie(&http.Cookie{...}) — first-class cookie API on the action context. The framework writes the Set-Cookie header on the redirect response, so the browser stores it before the WebSocket connects. HttpOnly, SameSite=Strict, and a 1-hour MaxAge are sensible defaults for a session token; production would also set Secure: true under HTTPS.
ctx.Redirect("/", http.StatusSeeOther) — the action returns its modified state and a redirect. POST-Redirect-GET: the framework writes the 303, the browser follows it, and the next GET renders the dashboard branch of the template against the new state.
The flash message API works the same way it does for non-auth flows — ctx.SetFlash("error", "Invalid credentials") stashes the message in a cookie, and the next render reads it via {{.lvt.FlashTag "error"}}.
The login form looks like a normal HTML form with one extra attribute:
<article>
<h1>Login</h1>
{{.lvt.FlashTag "error"}}
<form method="POST" lvt-form:no-intercept>
<label for="username">Username
<input type="text" id="username" name="username" placeholder="Enter username" required {{.lvt.AriaInvalid "username"}}>
{{.lvt.ErrorTag "username"}}
</label>
<label for="password">Password
<input type="password" id="password" name="password" placeholder="Enter password" required {{.lvt.AriaInvalid "password"}}>
{{.lvt.ErrorTag "password"}}
</label>
<button type="submit" name="login" {{.lvt.AriaDisabled "username" "password"}}>Login</button>
</form>
<small>
Demo: Use any username with password <strong>secret</strong><br>
After login, the server will push a welcome message via WebSocket.
</small>
</article>
lvt-form:no-intercept tells the LiveTemplate JS client not to intercept the submit. The form posts the natural browser way — application/x-www-form-urlencoded body, button-name routing (login) — and the response is a 303 redirect. This is what makes the auth flow work identically with and without JavaScript, and what makes the Set-Cookie header land on a real navigation response rather than a WebSocket frame.
OnConnect + session.TriggerActionAfter the redirect, the dashboard page loads, the JS client connects the WebSocket, and the framework calls the controller's OnConnect lifecycle method:
// OnConnect is called when a WebSocket connection is established.
// This is a lifecycle method on the controller.
func (c *AuthController) OnConnect(state AuthState, ctx *livetemplate.Context) (AuthState, error) {
session := ctx.Session()
log.Printf("WebSocket connected (user: %s, logged_in: %v)", state.Username, state.IsLoggedIn)
// Store session for server-initiated updates
if state.IsLoggedIn && session != nil {
c.mu.Lock()
c.sessions[state.Username] = session
c.mu.Unlock()
// Send a welcome message from the server.
// This demonstrates server-initiated updates after WebSocket connects.
go c.sendWelcomeMessage(state.Username, session)
}
return state, nil
}
// OnDisconnect is called when a WebSocket connection is closed.
func (c *AuthController) OnDisconnect() {
log.Printf("WebSocket disconnected")
}
Two things to notice. First, ctx.Session() returns the live session handle — the same one that broadcasts and triggers actions run through. Storing it in the controller's sessions map is a stand-in for what a real app would do via SessionStore and a background pub/sub channel.
Second, the actual welcome push happens in a goroutine. OnConnect returns immediately so the framework can finish the WebSocket handshake; the goroutine sleeps long enough for the dashboard to paint, then fires:
// sendWelcomeMessage sends a server-initiated welcome message via WebSocket.
// This demonstrates pushing updates from server to client without user action.
func (c *AuthController) sendWelcomeMessage(username string, session livetemplate.Session) {
// Small delay so the page fully renders first
time.Sleep(500 * time.Millisecond)
// Trigger server-initiated action that returns modified state with the welcome data.
// This updates the state and sends the update to all user's connections.
if err := session.TriggerAction("serverWelcome", map[string]interface{}{
"message": fmt.Sprintf("Welcome %s! This message was pushed from the server at %s",
username, time.Now().Format("15:04:05")),
}); err != nil {
log.Printf("Failed to send welcome message: %v", err)
} else {
log.Printf("Server-initiated welcome message sent to %s", username)
}
}
session.TriggerAction("serverWelcome", data) enqueues an action on the session as if the client had dispatched it. The framework routes it to the controller's ServerWelcome method, runs the standard state-mutation-and-diff pipeline, and patches the dashboard — specifically the <ins id="server-welcome-message"> block — with the new message. The client never asked for it; the server decided to update.
The same pattern is how you push subscription updates, completed background-job results, or any "the server learned something new" event. For the deeper model of when to broadcast (peer connections in the same session group) versus trigger (server-owned work on one session), see Broadcast & Server Push.
Logout mirrors login's shape — another lvt-form:no-intercept form, another action method, another redirect:
// Logout handles the "logout" action.
func (c *AuthController) Logout(state AuthState, ctx *livetemplate.Context) (AuthState, error) {
state.Username = ""
state.IsLoggedIn = false
state.ServerMessage = ""
// Delete session cookie
err := ctx.DeleteCookie("session_token")
if err != nil {
return state, fmt.Errorf("failed to delete cookie: %w", err)
}
// Redirect to login page
return state, ctx.Redirect("/", http.StatusSeeOther)
}
ctx.DeleteCookie writes Set-Cookie: session_token=; MaxAge=-1, the browser drops the cookie, the redirect lands on the login form, and the cycle starts over.
Three things a production auth flow needs that this recipe deliberately doesn't have:
session_<username>_<timestamp> corresponds to a real prior login. A real implementation would use the cookie as a key into a session store (Redis, SQLite, Postgres) and reject requests whose tokens don't match.secret. A real implementation would store bcrypt/argon2 hashes and compare via subtle.ConstantTimeCompare.Authenticator — every browser gets a fresh session group by default. A real app would implement Authenticator and have its Authenticate method consult the session store, so ctx.UserID() is populated for every action.The framework primitives shown here — cookies, no-intercept forms, OnConnect, TriggerAction — compose cleanly with all three additions. The auth shape doesn't change as you harden it; the implementations of the cookie validator, password compare, and Authenticate method swap in.
For a header-driven alternative that does have an Authenticator, see the Shared notepad recipe — BasicAuth instead of form login, with ctx.UserID() driving per-user state isolation.
Authenticator interface contract.session.TriggerAction, SessionStore, and the session group model.