# The Security Middleware Suite: CORS / CSRF / JWT / Rate Limit / Binder

> Gortex keeps body caps, CORS, CSRF, JWT, rate limiting and log redaction at framework level; JWT is pinned to HS256 and per-IP limiting needs TrustedProxies.

Source: https://sheng.page/en/posts/gortex-security-middleware-suite/ · Published: 2026-06-13 · Author: Sheng

Gortex handles body caps, CORS, CSRF, JWT, rate limiting, and log redaction at the framework layer. Keeping those controls together avoids configuring a different security baseline in every service.

> Prerequisite: [net/http and the Middleware Pattern](/en/posts/go-net-http-and-middleware/)

## Binder: body caps

The most direct untrusted input is the request body. The Binder caps a JSON body at 1 MiB (`DefaultMaxJSONBodyBytes = 1 << 20`) and enforces it with `http.MaxBytesReader` as it reads — an oversized body fails mid-read rather than being buffered into memory first:

```go
const DefaultMaxJSONBodyBytes int64 = 1 << 20 // 1 MiB
limited := http.MaxBytesReader(c.Response(), originalBody, limit)
// decode `limited`; a body over the limit fails here instead of OOMing
```

Multipart has its own cap (32 MiB per the framework docs). It's the most basic move there is — bound untrusted input at the boundary — and it shuts down the oversize-body DoS.

## CORS / CSRF

CORS: `CORSHandlerWithConfig` sets `Access-Control-Allow-Origin` / `-Methods` / `-Credentials` and friends from a `CORSConfig`, and adds `Vary`. Note the default `AllowOrigins` is `["*"]` — handy in development, but tighten it to a whitelist for production. Changing that default once at the framework layer for every service is exactly the dependency consolidation from [Why Build a Kitchen-Sink Framework](/en/posts/gortex-why-kitchen-sink-framework/).

CSRF: a double-submit token. A random token (32 bytes by default) goes in a cookie, and the client must echo it back in a header; a mismatch is `ErrCSRFTokenMismatch`, a missing one is `ErrCSRFTokenMissing`, both 403. The cookie's `SameSite` defaults to Lax, and a `Skipper` can bypass safe routes.

## JWT: 32-byte, HS256, typ

The JWT layer has three deliberate hard rules:

1. **secret ≥ 32 bytes**: `NewJWTService` rejects any secret shorter than `MinJWTSecretBytes` (32, matching the SHA-256 output length) with `ErrJWTSecretTooShort`, and `config.Validate` enforces it at config-load time. A short key weakens the HMAC.
2. **HS256 only**: on verify, `keyFuncHS256` checks `token.Method == jwt.SigningMethodHS256`, rejecting even HS384/HS512.

```go
func (s *JWTService) keyFuncHS256(token *jwt.Token) (any, error) {
    if token.Method != jwt.SigningMethodHS256 {
        return nil, ErrUnexpectedSigningMethod // blocks the algorithm-confusion attack
    }
    return s.secret, nil
}
```

   Why it matters: asserting on `*jwt.SigningMethodHMAC` alone would also accept other HMAC algorithms — the classic algorithm-confusion vulnerability. Pinning HS256 is what makes it safe.
3. **typ claim**: tokens carry a `typ` (`access` / `refresh`). Passing a refresh token where an access token is expected returns `ErrInvalidTokenType`, and legacy tokens with no `typ` are rejected outright.

## Rate limiting and TrustedProxies

The rate limiter is a per-IP token bucket with a background goroutine pruning expired entries, and it emits `X-RateLimit-Limit` / `-Remaining` / `-Reset`, plus `Retry-After` when a request is throttled.

But per-IP limiting is only as trustworthy as the IP. A client can simply set a fake `X-Forwarded-For` to change its apparent IP and slip past the limit. Hence `TrustedProxies` (a set of CIDRs): `X-Forwarded-For` / `X-Real-IP` are honoured only for requests from a trusted proxy; otherwise the forwarding headers are ignored and the real `RemoteAddr` is used. Without it, per-IP limiting is theatre.

## Log redaction

Logs are where passwords and tokens accidentally end up. Gortex's logger uses a `BodyRedactor` to mask sensitive fields in the request body (defaulting to `DefaultBodyRedactor`), and treats headers and query strings the same way.

There's a limitation worth stating plainly: **`LogResponseBody` is a no-op** — the source comment says it's deprecated and has no effect; the framework doesn't support logging response bodies. 4xx / 5xx are logged at Warn / Error using the *real* status code from the tracked responseWriter in [The Zero-Allocation Secret: Context Pool and smartParams](/en/posts/gortex-zero-allocation-context/). Being clear about what is and isn't logged beats pretending to do everything.


## Notes

- The Binder uses `http.MaxBytesReader` to cap a JSON body at 1 MiB (multipart has its own cap), shutting down oversize-body DoS.
- CORS allows configurable origins (default `*`, tighten for production); CSRF uses a double-submit token (32 bytes, SameSite Lax by default), with missing / mismatched → 403.
- JWT: secret ≥ 32 bytes, HS256-only (blocks algorithm confusion), a `typ` claim splitting access / refresh, and legacy no-typ tokens rejected.
- Rate limiting emits `X-RateLimit-*` / `Retry-After`; but per-IP limiting needs `TrustedProxies` or a forged `X-Forwarded-For` slips past it.
- Logs redact sensitive request-body fields; `LogResponseBody` is a no-op, and 4xx / 5xx use the tracked writer's real status at Warn / Error.

Source: [yshengliao/gortex](https://github.com/yshengliao/gortex).

---

> Ideas and technical judgement by Sheng; drafted with Claude · Go 1.25 (gortex go.mod) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.