Skip to content
gohacks.dev
Web

Templ Forms Validation Errors and CSRF Tokens in Go

Build an accessible templ form in Go with errors beside each field, kept values, a redirect after success and CSRF protection from Go 1.25.

9 min read
Summarize in AI
Flat illustration of a shield with a lock, a key and a password field.

A form is where a web application meets the least predictable input it will ever see. Most of the work is not accepting good data, it is responding well to bad data: saying what is wrong, next to the field it concerns, without making the person retype everything, and in a way that works for someone using a screen reader. A good form also has to refuse a request that a malicious page forged on the visitor's behalf. This article builds a sign-up form in templ that does all of that, and tests the parts that are easy to get wrong.

Tested with Go 1.25.6 and templ v0.3.1001, macOS on Apple Silicon (arm64)

It builds on the tutorial and relates to the html/template versions in form validation and CSRF protection. It belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with templ v0.3.1001.

One component for both states

The form is a single component that takes what the user submitted and what was wrong with it.

signup.templText
package main

import "fmt"

type Values struct {
	Name, Email, Age, Password string
	Terms                      bool
}

type Errors map[string]string

// Field renders a labelled input with its error message wired up for assistive technology.
templ Field(name, label, kind, value, errMsg string) {
	<div class="field">
		<label for={ name }>{ label }</label>
		<input
			id={ name }
			name={ name }
			type={ kind }
			value={ value }
			if errMsg != "" {
				aria-invalid="true"
				aria-describedby={ name + "-error" }
			}
		/>
		if errMsg != "" {
			<p id={ name + "-error" } class="error" role="alert">{ errMsg }</p>
		}
	</div>
}

templ SignupForm(v Values, errs Errors) {
	<form method="post" action="/signup" novalidate>
		if len(errs) > 0 {
			<p class="summary" role="alert">{ fmt.Sprint(len(errs)) } problem(s) to fix</p>
		}
		@Field("name", "Name", "text", v.Name, errs["name"])
		@Field("email", "Email", "email", v.Email, errs["email"])
		@Field("age", "Age", "number", v.Age, errs["age"])
		@Field("password", "Password", "password", "", errs["password"])
		<div class="field">
			<label>
				<input
					type="checkbox"
					name="terms"
					value="yes"
					if v.Terms {
						checked
					}
				/>
				I accept the terms
			</label>
			if errs["terms"] != "" {
				<p class="error" role="alert">{ errs["terms"] }</p>
			}
		</div>
		<button type="submit">Create account</button>
	</form>
}

The design has three choices worth defending.

One component, two jobs. SignupForm(Values{}, nil) is the empty form and SignupForm(v, errs) is the form after a failed submit. There is no second template for the error state, so a new field added to one is in the other. The Values struct holds raw strings, including Age, which is a string until validation proves it is a number; keeping it as typed input makes it possible to show back exactly what was typed.

Accessible errors. The Field component links each message to its input. When there is an error, the input gets aria-invalid="true" and aria-describedby pointing at the message element's id, so a screen reader announces the problem when the field gains focus. The message has role="alert", which makes it announced when it appears, and the summary paragraph at the top has it too. Notice that the attributes are added by an if inside the tag, and the id is built with ordinary Go concatenation: name + "-error".

The password is never written back. Field("password", ..., "", ...) passes an empty value. Echoing a password into the HTML puts it in the response body, the browser's memory and any intermediary cache. The person retypes it. Everything else the user typed comes back.

The handler

handler.goGo
package main

import (
	"net/http"
	"net/mail"
	"strconv"
	"strings"
	"unicode/utf8"

	"github.com/a-h/templ"
)

func validate(v Values) Errors {
	errs := Errors{}
	if n := utf8.RuneCountInString(strings.TrimSpace(v.Name)); n < 2 || n > 60 {
		errs["name"] = "Enter a name between 2 and 60 characters"
	}
	if a, err := mail.ParseAddress(v.Email); err != nil || a.Address != v.Email {
		errs["email"] = "Enter a valid email address"
	}
	if age, err := strconv.Atoi(v.Age); err != nil || age < 13 || age > 120 {
		errs["age"] = "Age must be a number from 13 to 120"
	}
	if len(v.Password) < 8 {
		errs["password"] = "Use at least 8 characters"
	}
	if !v.Terms {
		errs["terms"] = "You must accept the terms"
	}
	return errs
}

func Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /signup", func(w http.ResponseWriter, r *http.Request) {
		templ.Handler(SignupForm(Values{}, nil)).ServeHTTP(w, r)
	})
	mux.HandleFunc("POST /signup", func(w http.ResponseWriter, r *http.Request) {
		r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
		if err := r.ParseForm(); err != nil {
			http.Error(w, "bad form", http.StatusBadRequest)
			return
		}
		v := Values{
			Name: strings.TrimSpace(r.PostForm.Get("name")), Email: strings.TrimSpace(r.PostForm.Get("email")),
			Age: strings.TrimSpace(r.PostForm.Get("age")), Password: r.PostForm.Get("password"), Terms: r.PostForm.Get("terms") == "yes",
		}
		if errs := validate(v); len(errs) > 0 {
			// 422 with the form again: values kept (except the password), errors beside the fields.
			templ.Handler(SignupForm(v, errs), templ.WithStatus(http.StatusUnprocessableEntity)).ServeHTTP(w, r)
			return
		}
		http.Redirect(w, r, "/welcome", http.StatusSeeOther) // never render a page from a successful POST
	})
	mux.HandleFunc("GET /welcome", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("welcome")) })

	// Go 1.25: reject cross-origin browser POSTs using Sec-Fetch-Site and Origin; no tokens to store.
	cop := http.NewCrossOriginProtection()
	return cop.Handler(mux)
}

validate is plain Go returning a map, which makes it a one-line table test. It checks the name by rune count and not bytes (an accented name is not longer than a plain one), the email with net/mail and a check that the parsed address equals the input (so Mara <[email protected]> is rejected where a bare address is wanted), the age as an integer in a range, and the password length.

On failure the handler answers 422 Unprocessable Content with the form again. Some tutorials return 200 here. A 422 is the more accurate status, it is visible in logs, and it lets clients and tools tell a rejected submit from a served page; the only wrinkle is that HTMX does not swap 4xx responses unless configured, as noted in the HTMX article. On success it redirects with 303 See Other to a different page. That is the post-redirect-get pattern: if the successful POST rendered a page directly, the browser's refresh button would offer to resubmit the form and create a second account.

CSRF: letting the browser tell you

A cross-site request forgery works because browsers send cookies with requests that another site triggers: a hidden form on evil.example posting to your /signup or /transfer carries the victim's session. The classic defence is a token in a hidden field that the attacker cannot read. Go 1.25 added a simpler one to the standard library. http.NewCrossOriginProtection() checks the Sec-Fetch-Site header that every modern browser attaches to requests, and falls back to comparing the Origin header with Host, and refuses unsafe methods (anything other than GET, HEAD and OPTIONS) that arrive from another origin.

main.goGo
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"regexp"
	"strings"
)

func main() {
	srv := httptest.NewServer(Routes())
	defer srv.Close()
	client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}

	post := func(label string, form url.Values, headers ...string) {
		req, _ := http.NewRequest("POST", srv.URL+"/signup", strings.NewReader(form.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		for i := 0; i+1 < len(headers); i += 2 {
			req.Header.Set(headers[i], headers[i+1])
		}
		res, _ := client.Do(req)
		b, _ := io.ReadAll(res.Body)
		res.Body.Close()
		extra := ""
		if res.StatusCode == 303 {
			extra = " -> " + res.Header.Get("Location")
		}
		if res.StatusCode == 422 {
			errs := regexp.MustCompile(`class="error" role="alert">([^<]+)<`).FindAllStringSubmatch(string(b), -1)
			var msgs []string
			for _, m := range errs {
				msgs = append(msgs, m[1])
			}
			extra = fmt.Sprintf(" %d errors; password kept in page: %v; name kept: %v", len(msgs), strings.Contains(string(b), "hunter22"), strings.Contains(string(b), `value="Mara"`))
		}
		fmt.Printf("%-34s %d%s\n", label, res.StatusCode, extra)
	}

	good := url.Values{"name": {"Mara"}, "email": {"[email protected]"}, "age": {"34"}, "password": {"correct horse"}, "terms": {"yes"}}
	bad := url.Values{"name": {"Mara"}, "email": {"not-an-email"}, "age": {"7"}, "password": {"hunter22"}}

	post("valid, no browser headers", good)
	post("invalid input", bad)
	post("same-origin browser request", good, "Sec-Fetch-Site", "same-origin")
	post("cross-site browser request", good, "Sec-Fetch-Site", "cross-site")
	post("same-site, other subdomain", good, "Sec-Fetch-Site", "same-site")
	post("older browser, foreign Origin", good, "Origin", "https://evil.example")
	post("older browser, matching Origin", good, "Origin", srv.URL)

	res, _ := http.Get(srv.URL + "/signup")
	res.Body.Close()
	fmt.Println("GET is never blocked:", res.StatusCode)
}
Shell
go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go run .
Text
valid, no browser headers          303 -> /welcome
invalid input                      422 3 errors; password kept in page: false; name kept: true
same-origin browser request        303 -> /welcome
cross-site browser request         403
same-site, other subdomain         403
older browser, foreign Origin      403
older browser, matching Origin     303 -> /welcome
GET is never blocked: 200

The results are the behavior to rely on. A valid submission with no browser headers, which is what curl or a server-to-server client sends, is accepted: those requests are not the CSRF threat, because no browser is attaching cookies on an attacker's behalf. A same-origin browser request passes. A cross-site request is refused with 403, and so is same-site from another subdomain, which is the stricter and safer reading. An older browser that sends no Sec-Fetch-Site but does send Origin is judged by it: a foreign origin is refused, the matching one passes. GET is never blocked, which is why state-changing work must never be done in a GET handler.

The invalid-input line is the form check. The response is 422, there are three messages (email, age and terms), the typed name was kept, and the password string does not appear anywhere in the page.

Limits of header-based protection, and tokens

Know the edges before leaning on it. The check depends on the browser sending Sec-Fetch-Site or Origin; a request with neither is assumed to be same-origin or non-browser, as the first row shows. That is right for current browsers and wrong for a very old one, so if your audience includes such clients, add a token. Cross-origin protection also does not replace SameSite cookies, which you should set on the session cookie (Lax or Strict) as a second layer. API endpoints that legitimately accept cross-origin browser requests need AddTrustedOrigin, and endpoints called by other servers with a signature, such as webhook receivers, need AddInsecureBypassPattern because their security comes from the signature and not from the origin.

When you do want tokens, the pattern with templ is a hidden field filled from the request context, so no component needs a parameter for it:

Text
templ CSRFField() {
	<input type="hidden" name="csrf" value={ csrf.Token(ctx) }/>
}

A middleware generates a random token per session, stores it, puts it in the context, and verifies it on every unsafe request with a constant-time comparison. CSRFField goes inside every form (and into HTMX requests through hx-headers). Both approaches can run together: the standard library check as the baseline, and a token for defence in depth. The official description of the types is in the net/http documentation, and MDN covers the Fetch Metadata headers it relies on.

Testing a form

Three tests cover most of the risk. A table test of validate with one row per rule and the boundaries (13 and 120 for age, 7 and 8 characters for the password, a two-rune name). A handler test that posts an invalid form and asserts the status is 422, that every expected message is present next to its field (aria-describedby values match the error ids), and that the password value is absent. And a CSRF test like the one above, run against the same handler you deploy, because protection that is not wired in the real router protects nothing. Add a test that a successful post redirects with 303 and sets no body, which pins the post-redirect-get behavior against a later refactor.

Also inSecurity
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.