Login: form-based session auth

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:

Login

Demo: Use any username with password secret
After login, the server will push a welcome message via WebSocket.

The state struct

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"`
}

controller.go:14-30

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.

Login: HTTP POST, cookie, redirect

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

controller.go:34-74

Three framework primitives carry the auth weight here:

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 form: opting out of WebSocket interception

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>

auth.html:40-58

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.

Server-push welcome: OnConnect + session.TriggerAction

After 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")
}

controller.go:105-130

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

controller.go:134-151

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: symmetric to login

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

controller.go:78-93

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.

Where this recipe stops

Three things a production auth flow needs that this recipe deliberately doesn't have:

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.

What next?