Auth & Security Hardening
The defaults are safe to deploy, but production APIs benefit from a few extra layers. This page collects the practical checklist.
Authentication
- Use
auth.JWTAuthwith an asymmetric algorithm (RS256/ES256) when tokens are issued by an external provider. SymmetricHS256works when the signing service and the API share infrastructure. - Publish the JWK Set over
https://. It is the only thing deciding which tokens verify, so plaintext hands anyone on the path the ability to mint their own; a non-loopbackhttp://URL warns at startup. SeeJWKSAuth. - Use
auth.JWKSAuth(jwksURL, opts…)when the issuer publishes a rotating JWK Set (/.well-known/jwks.json). It fetches and caches the keys, selects the signing key by the token’skid, and refetches on an unknownkidso key rotation needs no redeploy. AllJWTOptions(Issuer, Audience, claim mappings, ClockSkew) apply. Prefer this over pinning a single staticPublicKeyagainst an issuer that rotates. RSA (RS256/384/512) and EC (ES256/384/512) supported. - Set
JWTOptions.IssuerandAudienceso tokens issued for another audience are rejected. - Set
JWTOptions.TenantClaimfor multi-tenant APIs — the verified value ends up onctx.Auth.TenantIDand feedsdb.Tenancy. - Never accept anonymous writes by default. Register
auth.JWTAuth(orauth.APIKeyAuth) on the Auth step unscoped, and open the genuinely public routes withauth.AllowAnonymous. Scoping the authenticator onto a list of operations or models instead leaves everything it does not name covered by no auth registration at all — andForModel/ForOperationare inclusion-only, so the next model added is on the wrong side of that line with nothing to say so.
server.Pipeline.Auth.Register(auth.AllowAnonymous(),
maniflex.ForOperation(maniflex.OpList, maniflex.OpRead))
server.Pipeline.Auth.Register(auth.JWTAuth(secret, auth.JWTOptions{
Issuer: "https://accounts.example.com",
Audience: "https://api.example.com",
TenantClaim: "org_id",
}))
AllowAnonymous forgives an absent credential only. One that was presented
and failed — expired, wrongly signed, revoked, malformed — is still 401 on an
exempt route, so a caller cannot shed a restrictive role by corrupting a byte of
their own token.
Verifying tokens from an issuer that rotates its signing keys via JWKS:
server.Pipeline.Auth.Register(auth.JWKSAuth(
"https://accounts.example.com/.well-known/jwks.json",
auth.JWTOptions{
Issuer: "https://accounts.example.com",
Audience: "https://api.example.com",
TenantClaim: "org_id",
}))
Authorisation
- Gate sensitive operations with
auth.RequireRole. Don’t rely on the UI to hide them. - Use
db.Tenancyordb.ForceFilterfor row-level scoping. These run on the DB step so they apply to lists, reads, and writes uniformly — UI code cannot accidentally bypass them. - Strip privileged values with
validate.ForbiddenValuesfor role fields and similar — prevent a normal user from promoting themselves by including"role": "admin"in a payload.
Secrets and PII
- Hash passwords with
service.HashField, never store them raw. - Use
writeonlyon credential fields so they are accepted on input but never returned in responses. - Encrypt sensitive columns with
mfx:"encrypted"and a configuredKeyProvider. Pair with thekey:sub-option for per-domain keys. - Redact in responses with
response.RedactFieldwhen a column is visible to some callers and hidden from others. - Gate writes with
validate.FieldRolewhen a column is writable by some callers and not others (readonlyis all-or-nothing). Without it, a privileged field needs its own endpoint to keep it off the general PATCH.
Input
- Set
Config.QueryTimeoutso a slow query can’t tie up a connection indefinitely. - Cap body sizes with
body.MaxBodySizewhere you know the upper bound. The default 4 MB limit catches accidents, but a 10 KB endpoint should enforce 10 KB. - Strip unknown fields with
body.StripUnknownFieldsin environments where you want a strict contract — every accepted field appears on the model. - Validate beyond tags with
validate.RegexField,validate.UniqueField, andvalidate.CrossFieldValidate. The built-inmfx:rules cover the common cases; everything else belongs in middleware.
Output
- Set security headers globally via
response.AddHeader:Strict-Transport-Security,X-Content-Type-Options,Referrer-Policy. - List CORS origins explicitly with
response.CORSHeaders(origins...)— origins are required (there is no permissive wildcard default; it panics if you pass none), and"*"cannot be combined with credentials. Install it inConfig.HTTPMiddlewares, where preflight runs before Auth. - Cap rate-sensitive endpoints with
db.RateLimitso password resets and similar can’t be brute-forced.
Transport
-
Terminate TLS at the load balancer or reverse proxy, not in the maniflex process. The framework is HTTP/1.1 + HTTP/2 ready.
-
Name your proxies in
Config.TrustedProxies, not justTrustProxyHeaders. Proxy-header resolution is off by default: the client IP is the direct TCP peer, so a caller cannot forge it. Every IP-keyed feature —db.RateLimit, idempotency scoping, and read-audit records — depends on that address, so how you turn resolution on matters:Config{TrustedProxies: []string{"10.0.0.0/8"}} // your LB's CIDRsHeaders are then believed only from those peers, and the
X-Forwarded-Forchain is walked right-to-left past them — so the first address no trusted proxy vouched for wins. A client connecting directly cannot forge its address at all, and one behind the proxy cannot forge it either: a proxy appends the address it saw, so anything the client wrote sits to the left of the truth and is skipped. That holds whether the proxy extends the client’s header line (nginx, AWS ALB) or adds one of its own below it (HAProxy’soption forwardfor) — every line is joined into one chain before the walk. A non-empty list enables resolution on its own;TrustProxyHeadersis not also required.TrustProxyHeaders: truewithout a list is the legacy mode: the leftmostX-Forwarded-Forentry, from any peer — which is the entry a client controls. It is safe only if the proxy strips both inbound headers itself. It warns at startup and fails underConfig.Strict. -
Set
Config.PathPrefixto a non-default value if the proxy mounts the API at a custom path. Don’t rewrite paths inside the application. -
Register
auth.CSRFif — and only if — browsers authenticate with cookies. A bearer token read from JavaScript is not an ambient credential, so a token-authenticated API is not CSRF-vulnerable and the middleware exempts bearer requests by default. Cookie-borne sessions are the case that needs it. See CSRF for both modes. The admin panel carries its own, unconditionally — see Admin Panel — and configuring one does not affect the other.
Operations
- Use a JSON-emitting
sloghandler in production so logs are structured and ingestable by your aggregator. - Set
Config.ServiceName— every log line and audit record carries it. - Point
readinessProbeat{prefix}/readyandlivenessProbeat{prefix}/live; tuneConfig.HealthTimeoutshorter than the probe timeout. A liveness probe aimed at a database-backed endpoint turns a dependency outage into a restart loop across every replica. - Leave
Probes.PublishReadinessChecksoff unless{prefix}/readyis reachable only from inside the cluster. It writes the names of your dependencies and which are failing into a body the probes serve without authentication — they bypassPipeline.Authby design.Config.Probesalso gates or unmounts each probe; see Gating and unmounting the probes. Gate/ready, not/live: a 401 from a liveness probe gets the container killed mid-drain. - Use
Config.PanicLoggerto route panics to a different sink than the rest of the framework logs, so they are easier to alert on.
Audit
- Register
db.AuditLogatmaniflex.Afterfor mutating operations. The records carry actor, model, operation, and a diff of the affected row. - Use
maniflex.ModelConfig{Versioned: true}on sensitive models. Every change writes a row to a sibling{model}_historytable.
Checklist
A reasonable production stack:
// HTTP/router layer — configure before maniflex.New
cfg.HTTPMiddlewares = append(cfg.HTTPMiddlewares,
response.CORSHeaders("https://app.example.com"))
server := maniflex.New(cfg)
// Auth
server.Pipeline.Auth.Register(auth.JWTAuth(secret, jwtOpts))
server.Pipeline.Auth.Register(auth.RequireRole("admin"),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpDelete))
// Body
server.Pipeline.Deserialize.Register(body.MaxBodySize(32<<10),
maniflex.ForModel("PasswordReset"))
server.Pipeline.Validate.Register(body.StripUnknownFields())
// DB
server.Pipeline.DB.Register(db.Tenancy("org_id", tenantFromAuth))
server.Pipeline.DB.Register(db.RateLimit(db.RateLimitConfig{
RequestsPerMinute: 10,
Key: keyByIP,
}), maniflex.ForModel("PasswordReset"))
server.Pipeline.DB.Register(db.AuditLog(auditSink),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
maniflex.AtPosition(maniflex.After))
// Response pipeline
server.Pipeline.Response.Register(
response.AddHeader("Strict-Transport-Security", "max-age=63072000"))
server.ObserveRequests(response.Logging(slog.Default()))