Auth Middleware
The maniflex/middleware/auth package supplies authentication and authorisation
middleware. Each function returns a maniflex.MiddlewareFunc that either populates
ctx.Auth on success or aborts with 401/403 on failure. Most register on the
Auth step; the exceptions are called out where they occur — Enforce
(attribute policies) runs on the DB step so the record is available, and
ReadAudit runs on the Response step after a 2xx.
JWTAuth
Verifies a bearer JWT and populates ctx.Auth from its claims.
import "github.com/xaleel/maniflex/middleware/auth"
server.Pipeline.Auth.Register(
auth.JWTAuth("my-signing-secret", auth.JWTOptions{
Issuer: "my-app",
Audience: "api",
TenantClaim: "org_id", // copied into AuthInfo.TenantID
ScopesClaim: "scope", // copied into AuthInfo.Scopes
}),
)
Supports HMAC (HS256/384/512) when the secret is a string and asymmetric
algorithms (RS256/384/512) when JWTOptions.PublicKey is set — useful with
external identity providers (Auth0, Okta, Cognito, etc.). AuthMethod on
ctx.Auth is set to "jwt".
Where the token is read from
By default the token comes from Authorization, which must carry the
Bearer scheme — a bare token there is malformed, not merely unadorned. Point
JWTOptions.Header at another header to read it from there instead:
auth.JWTAuth(secret, auth.JWTOptions{Header: "X-Auth-Token"})
A custom header accepts the token with or without the Bearer prefix, so a
client that sends it out of habit is not punished for it. The scheme name is
matched case-insensitively on both, per RFC 7235 — bearer <token> is valid.
Tokens must carry an exp claim: one with no expiry is rejected
(401 TOKEN_MISSING_EXPIRY), since it would otherwise be valid forever. Set
JWTOptions.AllowNoExpiry to accept non-expiring tokens from issuers that
deliberately mint them. On the HMAC path the signing secret must be non-empty (an
empty secret panics at startup) and should be at least 32 bytes — a shorter
secret is allowed but logs a warning.
JWKSAuth
Verifies asymmetric JWTs against a rotating JWK Set published by an identity
provider (e.g. an issuer’s /.well-known/jwks.json), instead of pinning a single
static key. Signing keys are fetched, cached, and selected by the token header’s
kid; an unknown kid triggers a rate-limited refetch, so a rotated key is
picked up without a redeploy. RSA (RS256/384/512) and EC (ES256/384/512) are
supported.
server.Pipeline.Auth.Register(auth.JWKSAuth(
"https://issuer.example.com/.well-known/jwks.json",
auth.JWTOptions{Issuer: "https://issuer.example.com", Audience: "api"},
))
All JWTOptions (Issuer, Audience, claim mappings, ClockSkew) apply exactly
as with JWTAuth — reach for the static-key JWTAuth only when the key is fixed
or for offline tests. See
Auth & Security Hardening for the
production checklist.
The JWKS URL must be https://
The JWK Set is the entire root of trust here. There is no shared secret: a
token is accepted if its signature verifies against a key from that URL, and
Issuer and Audience are claims inside the token, checked only once the
signature already has. Anyone who can control the bytes that URL returns can
therefore serve their own public key and mint tokens for any identity they like.
A plaintext URL logs a warning at construction. Two things make a moment of interception last much longer than the moment:
- a fetch replaces the whole key map and caches it for an hour, so one intercepted response buys an hour with nobody on the wire;
- when a later refresh fails, a cached key of any age is still used — so breaking the endpoint afterwards keeps an injected key alive indefinitely.
http://localhost and other loopback addresses are exempt and log nothing: a
local Keycloak or dex is an ordinary development setup and there is no wire to
intercept. A private LAN address such as 192.168.1.10 is not exempt — it is
still another host, reached over a network someone may be sitting on.
Redirects are policed as well as the configured URL. Go’s default HTTP client
follows https:// → http:// without complaint, so an issuer that redirects
could move key material onto plaintext while your configured https:// URL
looks untouched. Such a redirect is refused and the fetch fails.
APIKeyAuth
Validates a static API key from a request header. Each entry maps one key to
the AuthInfo it grants.
server.Pipeline.Auth.Register(auth.APIKeyAuth("X-API-Key",
auth.APIKeyEntry{Key: "svc-abc", Auth: maniflex.AuthInfo{
UserID: "svc-1", Roles: []string{"admin"},
}},
auth.APIKeyEntry{Key: "svc-xyz", Auth: maniflex.AuthInfo{
UserID: "svc-2", Roles: []string{"reader"},
}},
))
AuthMethod on ctx.Auth is set to "api_key". Combine with JWTAuth on
the same step to accept either credential — the first match wins.
Keys are indexed by their SHA-256 digest rather than by the key itself, so
lookup timing is a function of the digest and not of the secret, and the raw
keys are not retained in the index. Lookup stays O(1) — comparing every entry
with subtle.ConstantTimeCompare would close the same gap but scan every
configured key on every authenticated request.
These are static keys held in memory: they are as good as the deployment’s
secret handling, they cannot be revoked without a restart, and they do not
expire. For rotation, revocation, or per-user credentials, use JWTAuth with an
auth.Revoker.
RequireRole
Rejects the request unless ctx.Auth.Roles contains the named role. Typically
registered with ForModel / ForOperation to scope where the check applies.
server.Pipeline.Auth.Register(
auth.RequireRole("admin"),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpDelete),
)
Both failure cases return 403 FORBIDDEN: an anonymous request (ctx.Auth == nil) and an authenticated request lacking the role are treated alike. This
differs from RequireOwner, which answers 401 to an anonymous caller.
RequireScope and RequireAnyScope
Reject the request unless ctx.Auth.Scopes carries the OAuth2 grants the route
needs. JWTAuth fills that slice from JWTOptions.ScopesClaim (default
"scope"), accepting both the JSON array form and RFC 6749’s space-delimited
string.
RequireScope requires every listed scope:
server.Pipeline.Auth.Register(
auth.RequireScope("posts:read", "posts:write"),
maniflex.ForModel("Post"), maniflex.ForOperation(maniflex.OpUpdate),
)
That is the opposite of RequireRole, deliberately. A role names who the caller
is, so holding one of several is the usual question; a scope names a grant that
was issued, so an endpoint that both reads and writes needs both grants — not
either. Where several grants really are each sufficient, ask for that explicitly:
server.Pipeline.Auth.Register(auth.RequireAnyScope("posts:write", "admin"))
The refusal names only the scopes the caller is missing, so a client granted three of four is told which one to go and ask for.
Scopes match exactly. A read:* grant does not satisfy read:posts — the
framework cannot know whether :, /, or nothing at all delimits your issuer’s
hierarchy. Express one with Enforce and a Policy.
Both constructors panic when given no scopes. Under all-of semantics an empty requirement is vacuously satisfied, so such a guard would admit every request while reading as though it protected the route.
Like RequireRole, both answer 403 FORBIDDEN to an anonymous caller and to an
authenticated one lacking the grant.
RequireOwner
Enforces that the authenticated user owns the record being read or written. On
create it stamps ownerField = ctx.Auth.UserID automatically; on read, update,
and delete it fetches the target and compares its ownerField to the caller —
answering 404 (not 403, so the endpoint never reveals that a record it doesn’t
own exists). ownerField may be given as the JSON or the DB column name. Callers
holding any role in adminRoles bypass the check.
server.Pipeline.Auth.Register(auth.RequireOwner("user_id", "admin"))
RequireOwner scopes single-resource operations only — a collection GET
still returns every row. Constrain list reads with db.ForceFilter or
db.Tenancy on the DB step.
Enforce — attribute-based policies
RequireRole and RequireOwner answer who is calling; Enforce answers may
this principal touch this record, evaluating a Policy against the affected
row’s fields. A Policy is a plain function:
type Policy func(ctx *maniflex.ServerContext, resource map[string]any) (allow bool, err error)
Return (false, nil) for a 403 FORBIDDEN; a non-nil error becomes a 500.
Register on the DB step (not Auth), so the row is available:
sameTenant := func(ctx *maniflex.ServerContext, r map[string]any) (bool, error) {
return r["tenant_id"] == ctx.Auth.TenantID, nil
}
server.Pipeline.DB.Register(auth.Enforce(sameTenant), maniflex.ForModel("Patient"))
Which record the policy sees depends on the operation:
| Operation | Record checked | When |
|---|---|---|
OpCreate | the proposed body (ctx.ParsedBody) | before the insert |
OpUpdate / OpDelete | the current stored record | fetched before the write |
OpRead | the fetched record | after the DB step |
OpList | each row in turn | after the DB step; denied rows are dropped |
Compose policies with AllOf, AnyOf, and Not:
auth.Enforce(auth.AllOf(sameTenant, auth.AnyOf(isOwner, auth.Not(isArchived))))
For lists the policy runs per row after the query, so pagination totals
reflect the pre-filter count. When the rule can be expressed as a WHERE clause,
prefer db.ForceFilter — it scopes in SQL, keeps totals accurate, and
never fetches rows the caller can’t see.
AllowAnonymous
Marks the routes it is registered for as tolerating a request that carries no
credential at all. Register it before the authenticator and scope it with the
usual filters; JWTAuth and JWKSAuth honour it.
server.Pipeline.Auth.Register(auth.AllowAnonymous(),
maniflex.ForModel("Post"),
maniflex.ForOperation(maniflex.OpList, maniflex.OpRead),
)
server.Pipeline.Auth.Register(auth.JWTAuth(secret)) // everything else, unchanged
Exactly one thing changes: a request with no token is served with ctx.Auth left
nil instead of answering 401. A credential that was presented and failed —
expired, wrongly signed, revoked, malformed, or missing its Bearer scheme — is
still 401 here, as it is everywhere else. Degrading a bad token to anonymous
would turn an expired session into a silent change of permissions.
Two consequences worth planning around:
- The exemption is what you enumerate, not the coverage. The authenticator
stays registered globally, so a model added next month is protected by default.
Forgetting an entry in the exemption list produces a
401on the first request rather than an endpoint that quietly opens. - A token that is presented still authenticates. This is what scoping
JWTAuthaway from a route cannot do — there the middleware never runs, so even a valid token leavesctx.Authnil. UseAllowAnonymouswhen a route shows more to a signed-in visitor than to a stranger (published posts to everyone, drafts to their author).
ctx.Auth stays nil for the anonymous caller rather than being filled with a
blank principal, so every ctx.Auth == nil test keeps meaning what it means.
Ordering is by registration within a step: an AllowAnonymous registered after
the authenticator sets its marker too late to be read, which fails closed.
AllowPublicRead
A passthrough on OpRead and OpList; on every other operation it requires
ctx.Auth to be populated.
It does not, on its own, make reads reachable. An authenticator that aborts on
a missing credential has already answered 401 and returned without calling
next(), so an AllowPublicRead registered after it never runs — and registered
before it, the passthrough calls next() straight into the authenticator, which
aborts anyway. Pair it with AllowAnonymous:
server.Pipeline.Auth.Register(auth.AllowAnonymous(),
maniflex.ForOperation(maniflex.OpList, maniflex.OpRead),
)
server.Pipeline.Auth.Register(auth.JWTAuth("..."))
server.Pipeline.Auth.Register(auth.AllowPublicRead())
Here it is the belt to AllowAnonymous’s braces: one registration, covering every
model, re-asserting that nothing without a principal may write — a check no
per-route exemption list can drift away from.
BlockOperation
Refuses the listed operations outright, regardless of identity. Useful for making a model effectively read-only at the HTTP layer.
server.Pipeline.Auth.Register(
auth.BlockOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
maniflex.ForModel("AuditLog"),
)
The model’s routes remain mounted but always return 405 METHOD_NOT_ALLOWED.
Token revocation and logout
A JWT is valid until its exp and nothing the server does can take it back.
That is fine until a user logs out, changes their password, or has their account
compromised — at which point “valid until it expires” is exactly wrong. Set
JWTOptions.Revoker to give the server its say back, at the cost of a lookup
per request.
rev := auth.NewMemoryRevoker()
server.Pipeline.Auth.Register(auth.JWTAuth(secret, auth.JWTOptions{Revoker: rev}))
server.Action(auth.Logout(rev, "")) // POST /logout
server.Action(auth.LogoutAll(rev, "", 24*time.Hour)) // POST /logout-all
Logout revokes the token the caller authenticated with — logout on this device.
LogoutAll revokes every token belonging to the caller, including sessions whose
jti the server has never seen; it is the one to call after a password change.
Both respond 204, and both must be mounted behind the same auth middleware as
everything else (they read ctx.Auth).
The two granularities
| Call | Effect |
|---|---|
RevokeToken(ctx, jti, exp) | blocks one token; the entry may be dropped once exp passes, since the token is refused for being expired anyway |
RevokeUser(ctx, userID, cutoff, retainUntil) | blocks every token for the user issued before cutoff; keep the record until retainUntil, which must be past the exp of the longest-lived token you mint |
The per-user cutoff is what makes “log out everywhere” possible: the outstanding
jti values are unknown and usually unknowable, so a per-token blocklist alone
cannot express it. Logging in again works immediately — the cutoff kills tokens
issued before it, not the account.
What changes when it is on
- A
jtibecomes mandatory. A token without one cannot be revoked, so it is refused with401 TOKEN_NOT_REVOCABLErather than handed a permanent exemption from the blocklist. Mint a uniquejtiper token. - An
iatbecomes required for users who have a cutoff. A token that cannot be placed relative to the cutoff is refused. Tokens withoutiatkeep working for every user who has never calledLogoutAll. - A store outage refuses requests — see below.
Error codes:
| Code | Status | Meaning |
|---|---|---|
TOKEN_REVOKED | 401 | explicitly revoked; the client should discard it and log in again |
TOKEN_NOT_REVOCABLE | 401 | no jti, so it cannot be revoked — a minting bug, not a client error |
REVOCATION_UNAVAILABLE | 503 | the blocklist could not be consulted |
Failing closed
Every Revoker method returns an error, and the middleware refuses the request
when a lookup fails rather than reading “I could not check” as “not revoked”. A
blocklist that fails open silently un-revokes every logged-out token for the
duration of an outage — precisely when it matters most. This is why Revoker is
its own interface rather than a reuse of maniflex.CacheStore, whose Get
reports a miss and an outage identically.
The refusal is 503, not 401: the credential is fine, the server is not, and
answering 401 would push every healthy client into a re-login storm during an
incident.
Backends
NewMemoryRevoker() keeps the blocklist in-process. Its limitation is
structural: a second replica does not see a logout performed by the first, and a
restart loses every entry — which un-revokes every still-unexpired token. It
suits single-replica deployments, development, and tests.
Behind more than one replica, use a shared store. Two ship.
Redis — server-side key expiry, so nothing ever has to be swept:
import authredis "github.com/xaleel/maniflex/middleware/auth/redis"
rev := authredis.NewRevoker(redisClient, "myapp:revoked")
SQL — the database you already run, with no second piece of infrastructure
and no extra dependency (the package imports nothing but database/sql):
import authsql "github.com/xaleel/maniflex/middleware/auth/sql"
if err := authsql.Migrate(ctx, db, "postgres"); err != nil { // or "sqlite"
log.Fatal(err)
}
rev := authsql.NewRevoker(db)
Migrate creates revoked_token and revoked_user and is safe to run on every
boot and from every replica — every statement is IF NOT EXISTS, and none
rewrites an existing column. Use WithTablePrefix("auth_") if those names are
taken; pass the same option to both calls.
SQL has no TTL, so expired rows are swept rather than expiring themselves. This
never affects correctness — both reads filter on the deadline, so a row the sweep
has not reached yet stops being honoured at exactly the right moment — it only
bounds the table. The Revoker sweeps every 128 writes on its own; call
Prune(ctx) to do it on a schedule and see the error, or WithPruneEvery(0) to
turn the automatic sweep off and take over entirely:
tokens, users, err := rev.Prune(ctx)
One behavioural note when comparing the two: RevokeUser moves a cutoff forward
in the INSERT … ON CONFLICT itself, so concurrent revocations cannot resurrect
tokens by landing out of order. The Redis backend reads then writes and documents
that race as benign.
Or implement auth.Revoker yourself over any store — four methods, and the
interface is in terms of context.Context and time.Time only.
VerifyToken hook
JWTOptions.VerifyToken is the final say on a token that has passed every other
check — signature, registered claims, and revocation. Return an error to refuse
the request with 401 INVALID_TOKEN carrying that message.
auth.JWTAuth(secret, auth.JWTOptions{
VerifyToken: func(ctx *maniflex.ServerContext, claims map[string]any, info *maniflex.AuthInfo) error {
if claims["tier"] != "paid" {
return errors.New("subscription required")
}
info.Roles = append(info.Roles, "subscriber") // may enrich the principal
return nil
},
})
Use it for issuer-specific rules the framework does not model: a required custom
claim, a tenant allowlist, a per-user check against your own store. For
revocation specifically, prefer Revoker — it is the same hook point with the
blocklist and its failure semantics already written.
CSRF
Protects unsafe HTTP methods (POST/PUT/PATCH/DELETE) against cross-site
request forgery. Two strategies, selected by CSRFOptions.Mode:
CSRFDoubleSubmit(default) — issues a random token in a non-HttpOnly cookie on safe requests and requires the client to echo it in a header on unsafe ones.CSRFSignedToken— derives the expected token asHMAC(SessionID, Secret); stateless, no cookie issued. Register it after the JWT step soctx.Auth.SessionIDis populated (e.g. from the token’sjticlaim).
server.Pipeline.Auth.Register(auth.CSRF(auth.CSRFOptions{
AllowedOrigins: []string{"https://admin.example.com", "*.example.com"},
Secure: true,
}))
Bearer-authenticated requests are exempt by default — a bearer token read
from JS is not an ambient credential, so it isn’t CSRF-vulnerable. Set
EnforceBearer: true only if your bearer flow also rides browser-managed cookies.
The optional AllowedOrigins allowlist (exact origins or *.host wildcards) is
checked first on unsafe methods. Failures abort with a 403 carrying one of
CSRF_ORIGIN_REJECTED, CSRF_TOKEN_MISSING, CSRF_COOKIE_MISSING,
CSRF_NO_SESSION, or CSRF_TOKEN_MISMATCH.
From a login Action, hand the token to the SPA with auth.IssueCSRFCookie(w, opts) (double-submit) or auth.SignedCSRFToken(sessionID, secret) (signed mode).
The admin panel does not use this middleware. It ships its own always-on double-submit check over its own forms — see Admin Panel → CSRF protection. Registering
auth.CSRFneither configures nor disables it, and leavingauth.CSRFoff does not leave the panel unprotected.
ReadAudit
Writes a structured audit record after every successful read or list — the
read-side counterpart to db.AuditLog, for data where who looked is itself the
compliance control (clinical, financial). Implement a ReadAuditSink and register
ReadAudit at the After position on the Response step, so it fires only on
a 2xx:
server.Pipeline.Response.Register(
auth.ReadAudit(mySink),
maniflex.ForModel("Patient", "LabResult"),
maniflex.ForOperation(maniflex.OpRead, maniflex.OpList),
maniflex.AtPosition(maniflex.After),
)
Each ReadAuditRecord carries the actor, roles, tenant, session, request/trace
IDs, client IP, and either the accessed RecordID (read) or the RecordCount
(list). Writes are fire-and-forget — a goroutine with a 5 s timeout, so a slow
sink never delays the response — and sink errors are discarded, so back a lossless
requirement with a durable queue.
Scoping patterns
The middleware in this package combines with ForModel / ForOperation to
build per-route policy without writing custom Auth code:
// Public reads, JWT writes, admin-only deletes
server.Pipeline.Auth.Register(auth.AllowAnonymous(),
maniflex.ForOperation(maniflex.OpList, maniflex.OpRead))
server.Pipeline.Auth.Register(auth.JWTAuth("..."))
server.Pipeline.Auth.Register(auth.RequireRole("admin"),
maniflex.ForOperation(maniflex.OpDelete))
Note which registration carries the filter. Scoping the authenticator to the
write operations would work today and fail later: ForModel/ForOperation are
inclusion-only, so anything they do not name is covered by no auth registration at
all. Scoping the exemption instead leaves the authenticator global, so the
default for a route nobody listed is refusal.