Input Validation & Safe HTML/JSON Handling
Every HTTP and gRPC handler that accepts user input must validate structure, size, and semantics before touching databases, shells, or HTML output.
Go's parsers help with syntax, but they do not enforce your security policy.
Summary
Input validation in Go starts at the transport edge (body size limits, content types) and continues through typed decoding (encoding/json, json.Decoder, protobuf) and explicit rule checks (length, format, allowlists).
Safe output encoding depends on context: HTML needs html/template, JSON APIs need correct Content-Type and no embedded HTML in string fields without encoding.
Injection flaws (SQL, command, template, log) share one root cause: treating untrusted data as code or structure.
Validate early, parameterize queries, and fail with safe error messages.
Recipe
Quick-reference recipe card - copy-paste ready.
type CreateUser struct {
Email string `json:"email"`
Age int `json:"age"`
}
func decodeCreateUser(r *http.Request) (CreateUser, error) {
r.Body = http.MaxBytesReader(nil, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var u CreateUser
if err := dec.Decode(&u); err != nil {
return u, err
}
if !strings.Contains(u.Email, "@") || u.Age < 0 || u.Age > 150 {
return u, errors.New("invalid user")
}
return u, nil
}When to reach for this:
- REST handlers accepting JSON bodies from browsers or third parties.
- Admin forms rendered as HTML with user-supplied display names.
- gRPC handlers after protobuf decode, when business invariants still need checks.
- Webhook receivers that must reject malformed or oversized payloads quickly.
Working Example
package main
import (
"database/sql"
"encoding/json"
"errors"
"html/template"
"net/http"
"regexp"
"strings"
_ "modernc.org/sqlite"
)
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
type Profile struct {
DisplayName string `json:"display_name"`
Email string `json:"email"`
}
func validateProfile(p Profile) error {
p.DisplayName = strings.TrimSpace(p.DisplayName)
if len(p.DisplayName) == 0 || len(p.DisplayName) > 64 {
return errors.New("invalid display name")
}
if !emailRe.MatchString(p.Email) {
return errors.New("invalid email")
}
return nil
}
func createProfileHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var p Profile
if err := dec.Decode(&p); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := validateProfile(p); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
_, err := db.Exec(
"INSERT INTO profiles (display_name, email) VALUES (?, ?)",
p.DisplayName, p.Email,
)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
}
func renderProfileHandler() http.HandlerFunc {
tmpl := template.Must(template.New("page").Parse(`<h1>{{.}}</h1>`))
return func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if len(name) > 64 {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, name)
}
}
func main() {
db, _ := sql.Open("sqlite", ":memory:")
mux := http.NewServeMux()
mux.HandleFunc("POST /profiles", createProfileHandler(db))
mux.HandleFunc("GET /view", renderProfileHandler())
http.ListenAndServe(":8080", mux)
}What this demonstrates:
- Body size cap before JSON decode.
DisallowUnknownFieldsfor strict API contracts.- Separate validation function for business rules after syntax parse.
- Parameterized SQL with
?placeholders, not string formatting. html/templateauto-escaping for query parameter display.
Deep Dive
How It Works
json.Unmarshalandjson.Decodermap JSON to Go types; zero values fill missing fields unless you use pointers for optional detection.- Validation libraries (
go-playground/validator, custom functions) run after decode to enforce formats, ranges, and cross-field rules. DisallowUnknownFieldsrejects extra JSON keys - useful for public APIs; sometimes relaxed for forward-compatible internal APIs.- HTML context requires context-aware encoding;
html/templatehandles element text, while JavaScript or URL contexts need stricter rules or CSP. - SQL safety is entirely about placeholders:
db.Query("SELECT * FROM users WHERE id = ?", id)never interpolates user strings into the query text.
Validation Layers
| Layer | Check | Example |
|---|---|---|
| Transport | Size, content type | MaxBytesReader, reject non-application/json |
| Syntax | Parse success | json.Decoder.Decode, protobuf proto.Unmarshal |
| Schema | Types, required fields | Struct tags, DisallowUnknownFields |
| Semantic | Business rules | Email format, enum allowlist, ownership |
| Authorization | Who may set this field | Strip is_admin from public create DTOs |
Go Notes
// Avoid mass assignment: use separate DTOs for create vs update
type PublicCreateOrder struct {
SKU string `json:"sku"`
Quantity int `json:"quantity"`
// no Status, no UserID - server sets those
}Gotchas
- Trusting JSON after unmarshal only - Valid JSON can still carry impossible
Age: -1orRole: "admin". Always run semantic validation. - Mass assignment from client JSON - Binding directly into database models exposes fields clients must not set (
is_admin,owner_id). Use input DTOs. - text/template for HTML - Renders raw user input and enables XSS. Use
html/templateor return JSON only. - fmt.Sprintf SQL -
fmt.Sprintf("SELECT * FROM t WHERE id = %s", id)is injectable. Use placeholders always. - Reflecting internal errors - Returning
err.Error()from database drivers leaks schema hints. Log internally; respond with generic messages. - Skipping empty string vs missing - JSON
""and omitted field both become""onstringfields. Use pointers orsql.NullStringwhen distinction matters. - Log injection - Writing user input verbatim to logs can forge fake log lines. Sanitize or structured-log with escaping.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
go-playground/validator struct tags | Large DTO sets with standard formats | You need domain rules tags cannot express |
| OpenAPI + code generation | Contract-first public APIs | Small internal handlers where codegen is heavy |
| Protobuf + protovalidate | gRPC with strong schemas | Simple JSON-only REST services |
| WAF at ingress | Broad attack signatures | Substitute for app-level validation (never enough alone) |
FAQs
Should I use json.Unmarshal or json.Decoder?
Decoder streams and supports DisallowUnknownFields per request without allocating the full raw body.
Unmarshal is fine for small bodies after you have already capped size with MaxBytesReader.
When should DisallowUnknownFields be enabled?
Enable for versioned public APIs where unexpected fields signal client bugs or probing.
Internal services may allow unknown fields for forward compatibility if you document the policy.
How do I validate query parameters and path vars?
Read with r.URL.Query().Get or mux path vars, then validate length, regex, and allowlists before use.
Never pass raw path segments into SQL or shell commands.
Does html/template protect all XSS cases?
It auto-escapes HTML element text.
Embedding user data inside <script>, event handlers, or javascript: URLs still requires CSP and strict input rules.
How do I safely return JSON errors?
Use a fixed shape like {"error":"bad request"} without stack traces or driver messages.
Log the real error with request ID server-side.
Are protobuf messages validated automatically?
Protobuf enforces types on the wire, not business rules.
Add validation in handlers or use protovalidate / custom checks after unmarshal.
How do I prevent SSRF in handlers that fetch URLs?
Validate URLs against an allowlist of hosts and schemes.
Block link-local, metadata IPs, and private ranges before http.Get.
Should I validate on client and server?
Client validation improves UX.
Server validation is mandatory - clients can be bypassed entirely.
How do I handle file uploads safely?
Cap size, sniff MIME type from content not extension, store outside web root, and scan if policy requires.
Never execute uploaded content.
What about Unicode normalization attacks?
Normalize and compare emails and usernames with a defined NFC/NFKC policy before uniqueness checks.
Document the normalization rule to avoid duplicate account edge cases.
Can regexp alone validate emails?
Regex catches obvious garbage; full RFC email validation is complex.
Combine reasonable regex with confirmation flows for high-risk accounts.
How do I test validation logic?
Table-driven tests with boundary values: empty, max length, invalid enums, unknown JSON keys, and SQL metacharacters in string fields.
Related
- Go Security Basics - body limits and html/template intro
- Authentication & Authorization Patterns - authz after validation
- Secure Cookies & CSRF for Web Apps - browser-specific input risks
- Security for Go Services: Defaults and Gaps - validation as an application gap
- Security Best Practices Summary - validation checklist items
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify at build).