Reject Unknown Fields and Decode Strict JSON in Go
Decode request JSON strictly in Go: DisallowUnknownFields, a single-value check, body limits and a map from decoder errors to 400, 413 and 422 answers.

By default encoding/json is lenient. It ignores keys it does not know and stops after the first value it reads. For a config file that is handy. For a request body it means a client that misspells email as emial gets a user with no email and a 200, and a body with two objects glued together is half-accepted. Both are bugs the client will find in production, not in the call that caused them.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
A short helper fixes that, and the same helper can turn the decoder's errors into messages that help the client without leaking internals. It is the decode half of request validation, and it is used by the JSON API cornerstone. Everything ran on Go 1.25.6.
The helper
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
// DecodeStrict reads exactly one JSON value into dst, refusing unknown fields, trailing data and oversized bodies.
// It returns the HTTP status to answer with and a message safe to show a client.
func DecodeStrict(w http.ResponseWriter, r *http.Request, dst any) (int, error) {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
err := dec.Decode(dst)
var (
syntax *json.SyntaxError
typeErr *json.UnmarshalTypeError
tooBig *http.MaxBytesError
)
switch {
case err == nil:
case errors.Is(err, io.EOF):
return 400, errors.New("body must not be empty")
case errors.As(err, &syntax):
return 400, fmt.Errorf("malformed JSON at byte %d", syntax.Offset)
case errors.Is(err, io.ErrUnexpectedEOF):
return 400, errors.New("malformed JSON: the body ended early")
case errors.As(err, &typeErr):
return 422, fmt.Errorf("field %q must be of type %s", typeErr.Field, typeErr.Type)
case errors.As(err, &tooBig):
return 413, fmt.Errorf("body must be at most %d bytes", tooBig.Limit)
case strings.HasPrefix(err.Error(), "json: unknown field "):
return 422, errors.New(strings.Replace(err.Error(), "json: ", "", 1))
default:
return 400, errors.New("invalid JSON")
}
// Decode stops after the first value, so {"a":1}{"b":2} would otherwise pass.
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
return 400, errors.New("body must contain a single JSON value")
}
return 0, nil
}Four things happen, in order.
The body is bounded. http.MaxBytesReader caps the read at 1 MiB, and when the limit is exceeded the decoder's error wraps *http.MaxBytesError. Without the cap a client can stream gigabytes into a decode. The reader also tells the server to close the connection after an oversized body, which is what you want (limiting request body size).
Unknown keys are refused. DisallowUnknownFields makes the decoder return json: unknown field "emial" for a key with no matching struct field. The error is the only signal, so surface it: a client that sees the key name can fix it.
Errors are classified. errors.As finds the typed errors. *json.SyntaxError has an Offset, which is more useful to a client than the full text. *json.UnmarshalTypeError has Field and Type. An empty body returns io.EOF, and a body cut off mid-value returns io.ErrUnexpectedEOF. The unknown-field error is a plain errors.New in the standard library, so the helper matches the message prefix; that is a little ugly, so a test pins the message and an upgrade that changes it fails loudly.
There must be exactly one value. Decode stops after the first JSON value. The helper then calls Token and requires io.EOF, so {"a":1}{"b":2} and {"a":1} garbage are both refused.
What it answers
package main
import (
"fmt"
"net/http/httptest"
"strings"
)
type Signup struct {
Email string `json:"email"`
Age int `json:"age"`
}
func main() {
big := `{"email":"` + strings.Repeat("a", 2<<20) + `"}`
for _, c := range []struct{ label, body string }{
{"valid", `{"email":"[email protected]","age":30}`},
{"misspelled key", `{"emial":"[email protected]"}`},
{"extra key", `{"email":"[email protected]","admin":true}`},
{"wrong type", `{"email":"[email protected]","age":"thirty"}`},
{"empty body", ``},
{"truncated", `{"email":"a@b`},
{"syntax error", `{"email":"[email protected]",}`},
{"two values", `{"email":"[email protected]"}{"email":"x"}`},
{"trailing junk", `{"email":"[email protected]"} garbage`},
{"2 MiB body", big},
} {
var s Signup
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/signup", strings.NewReader(c.body))
status, err := DecodeStrict(w, r, &s)
msg := "ok"
if err != nil {
msg = err.Error()
}
fmt.Printf("%-16s %3d %s\n", c.label, status, msg)
}
}valid 0 ok
misspelled key 422 unknown field "emial"
extra key 422 unknown field "admin"
wrong type 422 field "age" must be of type int
empty body 400 body must not be empty
truncated 400 malformed JSON: the body ended early
syntax error 400 malformed JSON at byte 19
two values 400 body must contain a single JSON value
trailing junk 400 body must contain a single JSON value
2 MiB body 413 body must be at most 1048576 bytesThe misspelled key and the extra key are 422s that name the key. The wrong type names the field and the expected type. An empty body, a truncated body and a syntax error are 400s with different messages; the syntax error carries the byte offset, 19, which points at the trailing comma. Two values and trailing junk both get "single JSON value". A 2 MiB body is a 413 that states the limit.
The status split is a convention: 400 for bodies that are not valid JSON, 422 for valid JSON that does not fit the type, 413 for size. Choose your own and stay consistent, and return the message inside a problem document so clients branch on a stable type rather than on this text.
Tests
package main
import (
"net/http/httptest"
"strings"
"testing"
)
func decode(body string) (int, error) {
var s Signup
return DecodeStrict(httptest.NewRecorder(), httptest.NewRequest("POST", "/", strings.NewReader(body)), &s)
}
func TestUnknownFieldIsRejected(t *testing.T) {
if status, err := decode(`{"email":"[email protected]","extra":1}`); status != 422 || err == nil {
t.Fatalf("got %d %v", status, err)
}
}
func TestSecondValueIsRejected(t *testing.T) {
if status, _ := decode(`{"email":"a"}{"email":"b"}`); status != 400 {
t.Fatalf("got %d", status)
}
}
func TestValidBodyPasses(t *testing.T) {
if status, err := decode(`{"email":"[email protected]","age":3}`); status != 0 || err != nil {
t.Fatalf("got %d %v", status, err)
}
}=== RUN TestUnknownFieldIsRejected
--- PASS: TestUnknownFieldIsRejected (0.00s)
=== RUN TestSecondValueIsRejected
--- PASS: TestSecondValueIsRejected (0.00s)
=== RUN TestValidBodyPasses
--- PASS: TestValidBodyPasses (0.00s)
PASS
ok example.test/lab 1.295sThree tests cover the behaviors that matter: an unknown field is a 422, a second value is a 400, and a valid body passes. Add the case for your largest real request so a limit change shows up in review.
When strictness is wrong
Strict decoding suits request bodies you control. Be careful where the other side evolves faster than you do. A client of someone else's API should ignore unknown fields in responses, so the provider can add fields without breaking it; that is the "tolerant reader" rule. Webhook receivers sit in between: verify the signature on the raw bytes first, and decode leniently, since the sender may add fields at any time (webhook receivers). For your own API, it is worth saying in the documentation that clients must ignore unknown response fields while the server rejects unknown request fields. The rejection also helps with security: it makes a request that tries to set a field you did not intend, such as "is_admin": true, fail instead of vanishing quietly (mass assignment).
The decoder options are documented in the encoding/json package, and the size limit in http.MaxBytesReader.


