juanfont/headscale · warning · HTTPError

Bad Request: invalid JSON

Error message

Bad Request: invalid JSON

What it means

Returned by handleVerifyRequest when the /verify request body does not unmarshal into tailcfg.DERPAdmitClientRequest. The server expects a small JSON object with a NodePublic field; any other body (HTML error page, form data, truncated JSON) triggers this 400.

Source

Thrown at hscontrol/handlers.go:137

// verifyBodyLimit caps the request body for /verify. The DERP verify
// protocol payload ([tailcfg.DERPAdmitClientRequest]) is a few hundred
// bytes; 4 KiB is generous and prevents an unauthenticated client from
// OOMing the public router with arbitrarily large POSTs.
const verifyBodyLimit int64 = 4 * 1024

func (h *Headscale) handleVerifyRequest(
	req *http.Request,
	writer io.Writer,
) error {
	body, err := io.ReadAll(req.Body)
	if err != nil {
		return NewHTTPError(http.StatusRequestEntityTooLarge, "request body too large", fmt.Errorf("reading request body: %w", err))
	}

	var derpAdmitClientRequest tailcfg.DERPAdmitClientRequest
	if err := json.Unmarshal(body, &derpAdmitClientRequest); err != nil { //nolint:noinlineerr
		return NewHTTPError(http.StatusBadRequest, "Bad Request: invalid JSON", fmt.Errorf("parsing DERP client request: %w", err))
	}

	allow := h.state.ListNodes().ContainsFunc(func(n types.NodeView) bool {
		return n.NodeKey() == derpAdmitClientRequest.NodePublic
	})

	resp := &tailcfg.DERPAdmitClientResponse{
		Allow: allow,
	}

	return json.NewEncoder(writer).Encode(resp)
}

// VerifyHandler see https://github.com/tailscale/tailscale/blob/964282d34f06ecc06ce644769c66b0b31d118340/derp/derp_server.go#L1159
// DERP use verifyClientsURL to verify whether a client is allowed to connect to the DERP server.
func (h *Headscale) VerifyHandler(
	writer http.ResponseWriter,
	req *http.Request,

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Inspect the exact body being sent to /verify and compare against tailcfg.DERPAdmitClientRequest
  2. Ensure no reverse proxy rewrites or replaces the request body
  3. Confirm the DERP router and headscale versions agree on the verify protocol

Example fix

// before
curl -X POST http://headscale/verify -d 'not json'

// after
curl -X POST http://headscale/verify -H 'Content-Type: application/json' -d '{"NodePublic":"nodekey:abcdef..."}'
Defensive patterns

Strategy: validation

Validate before calling

if !json.Valid(body) {
    return fmt.Errorf("verify body is not valid JSON")
}
var r tailcfg.DERPAdmitClientRequest
if err := json.Unmarshal(body, &r); err != nil || r.NodePublic.IsZero() {
    return fmt.Errorf("body is not a DERPAdmitClientRequest")
}

Try / catch

if err := json.Unmarshal(body, &req); err != nil {
    return fmt.Errorf("parsing DERP client request: %w", err)
}

Prevention

When it happens

Trigger: POSTing non-JSON or malformed JSON to /verify, e.g. an empty body, a proxied HTML error page, or a JSON payload missing/mistyping the NodePublic field.

Common situations: Pointing a DERP verify client at the wrong URL; a reverse proxy intercepting the request and returning its own error body; version skew where the sender emits a different verify payload schema.

Understand the failure class

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/b3727310e0f5297d. Report an issue: GitHub.