go
55 lines · 8 steps
Hardening JSON decoding in Go HTTP handlers
A reusable decodeJSON helper that validates input and turns cryptic decoder errors into clear, client-friendly messages.
Explained by
highlit
1package handlers
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "net/http"
9 "strings"
10)
11
12type CreateUserRequest struct {
13 Email string `json:"email"`
14 Name string `json:"name"`
15 Password string `json:"password"`
16}
17
18func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
19 if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
20 return fmt.Errorf("unsupported content type %q", ct)
21 }
22
23 r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
24
25 dec := json.NewDecoder(r.Body)
26 dec.DisallowUnknownFields()
27
28 if err := dec.Decode(dst); err != nil {
29 var syntaxErr *json.SyntaxError
30 var typeErr *json.UnmarshalTypeError
31 var maxBytesErr *http.MaxBytesError
32
33 switch {
34 case errors.As(err, &syntaxErr):
35 return fmt.Errorf("malformed JSON at position %d", syntaxErr.Offset)
36 case errors.As(err, &typeErr):
37 return fmt.Errorf("invalid value for field %q", typeErr.Field)
38 case errors.Is(err, io.EOF):
39 return errors.New("request body must not be empty")
40 case errors.As(err, &maxBytesErr):
41 return fmt.Errorf("request body exceeds %d bytes", maxBytesErr.Limit)
42 case strings.HasPrefix(err.Error(), "json: unknown field "):
43 field := strings.TrimPrefix(err.Error(), "json: unknown field ")
44 return fmt.Errorf("unknown field %s", field)
45 default:
46 return err
47 }
48 }
49
50 if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
51 return errors.New("request body must contain a single JSON object")
52 }
53
54 return nil
55}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Bounding the request body with MaxBytesReader protects the server from memory exhaustion before parsing begins.
- 2errors.As and errors.Is let you inspect wrapped error types and produce specific, actionable messages.
- 3A second Decode call is the idiomatic way to reject trailing data after the first JSON object.
Related explainers
rust
use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(untagged)]
Parsing flexible JSON shapes with serde
deserialization
enums
json
Intermediate
6 steps
ruby
require "shellwords" require "open3" module Backup
Building safe shell commands in Ruby
shell-out
subprocess
command-injection
Intermediate
7 steps
go
package streaming import ( "bufio"
Streaming NDJSON logs over HTTP in Go
http-streaming
channels
select
Advanced
10 steps
go
package api import ( "crypto/sha256"
ETag conditional requests in Gin
http-caching
etag
conditional-requests
Intermediate
6 steps
go
func (w *Watcher) resetDebounce(d time.Duration) { if !w.timer.Stop() { select { case <-w.timer.C:
Debouncing a stream of events in Go
debounce
timers
channels
Advanced
7 steps
rust
use chrono::{Duration, NaiveDate}; #[derive(Debug)] pub struct DateRange {
Parsing and iterating date ranges in Rust
error-handling
iterators
parsing
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/hardening-json-decoding-in-go-http-handlers-explained-go-2723/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.