Deny by Default: Route-Aware Authorization With c.Endpoint() in Fiber v3.6
A pull request adds GET /api/admin/export, a CSV download of every customer
record. The handler is fine, the test for the CSV format is fine, and the
reviewer approves it. Two weeks later somebody notices that the route was
registered on the api group instead of the admin group, so the only thing
it checked was a valid login. Every customer with an account could download
every other customer. Nobody wrote a wrong access rule. The route simply never
had one, and nothing in the app noticed.
This is the most ordinary way to end up in A01 of the OWASP Top 10
2025, broken
access control, whose first piece of advice is to deny by default. In a Fiber
app, "default" usually means "whatever middleware the route happens to inherit
from its group". That works until the day a route lands in the wrong place.
Fiber v3.6.0 added
c.Endpoint(), which lets global middleware see the route
that will handle a request before any handler runs. This post uses it to build a
single policy table keyed by route name, a middleware that rejects every route
the table does not mention, and a startup check that refuses to boot when the
table and the routes disagree. Everything was built and tested against Fiber
v3.6.1. c.Endpoint(),
c.Bearer() and c.SetLocal() need v3.6.0 or later, which requires Go 1.26.
Why Global Middleware Used to Be Blindโ
The usual way to protect routes in Fiber is to attach middleware where the
routes live. A group gets api.Use(requireLogin), a sensitive route gets
admin.Get("/users", requireRole("admin"), listUsers), and the public health
check sits on the app without any of it. This is explicit and easy to read
route by route. Its weakness is that the safe state depends on every author
putting every route in the right spot. A route that is missing a check looks
exactly like a route that is meant to be public.
The alternative is one middleware in front of everything that decides based on
the route. Before v3.6.0 that middleware had a problem: it ran before routing
had picked an endpoint, so it had no idea which one would win. The obvious
candidate, c.Route(), does not help. It returns the route
that is currently executing, and inside an app.Use handler that is the
middleware itself. I printed it for a request to GET /api/x in a global
middleware and got a Path of "/" and an empty Name, for every request.
People worked around this by matching on c.Path() with prefixes, which
quietly breaks when someone renames a group, or by moving the check into each
handler, which is the scattering we wanted to avoid.
c.Endpoint() closes that gap. It returns the *fiber.Route the router would
reach for this request, without advancing the handler chain. You get the
registered Path (/api/notes/:id, not the concrete URL), the Method and the
Name you gave the route with .Name(). It returns nil when no endpoint will
run: on a 404 or 405, and while the error handler replays the chain for a
request that was rejected at the protocol level, for example one over
BodyLimit. ASP.NET Core's endpoint routing picks the endpoint first and lets
the authorization middleware read its metadata, and its
FallbackPolicy
applies to every endpoint that declares nothing. The design below is the same
idea in Fiber terms, with route names standing in for metadata.
The Plan: Names as the Contractโ
Fiber routes do not carry arbitrary metadata, but they carry a name, and names
are already used for GetRoute and URL generation. A
group can contribute a prefix: app.Group("/api").Name("api.") followed by
.Name("notes.list") on a route yields api.notes.list. So the policy table
is a plain map[string]Policy, and the route name is the key.
Three rules make this deny by default:
- A request whose endpoint has no entry in the table gets a
403. That includes routes nobody named, because their name is the empty string and the table never contains it. - At startup the app compares the table with
app.GetRoutes(true). Any route without a policy, any policy without a route, and any name shared by routes on different paths stops the process before it listens. - A test runs the same comparison, so the mistake shows up in the pull request and not at deploy time.
Without rules two and three, a forgotten policy would surface as a confused
user with a 403. That beats a data leak, but it is still a bug report.
A Notes Service With One Policy Tableโ
The example is a small notes API. It has a public health check, three routes
for any signed-in user, and an admin route. Tokens are a hard-coded map so the
example runs without a database. In a real service lookup would validate a
session or a JWT; the shape of the middleware does not change.
package main
import (
"errors"
"fmt"
"log"
"maps"
"slices"
"sort"
"strings"
"sync"
"github.com/gofiber/fiber/v3"
)
// User is whoever presented a valid token.
type User struct {
ID string
Roles []string
}
// Policy says who may call a route. The zero value means
// "any authenticated user".
type Policy struct {
Public bool // no token needed
Roles []string // if set, the user needs at least one of them
}
func (p Policy) allows(u User) bool {
if len(p.Roles) == 0 {
return true
}
return slices.ContainsFunc(u.Roles, func(r string) bool {
return slices.Contains(p.Roles, r)
})
}
var (
public = Policy{Public: true}
authenticated = Policy{}
)
func role(names ...string) Policy { return Policy{Roles: names} }
// Policies maps route names to their access rule. A route that is
// missing here is denied, and checkPolicies refuses to start the app.
var policies = map[string]Policy{
"health": public,
"api.notes.list": authenticated,
"api.notes.show": authenticated,
"api.notes.create": authenticated,
"api.admin.users": role("admin"),
}
type userKey struct{}
func currentUser(c fiber.Ctx) User {
return fiber.Locals[User](c, userKey{})
}
// authorize looks ahead to the route that will handle the request
// and applies its policy before any handler runs.
func authorize(
rules map[string]Policy,
lookup func(token string) (User, bool),
) fiber.Handler {
return func(c fiber.Ctx) error {
route := c.Endpoint()
if route == nil {
// 404 or 405: no handler will run, let the router answer.
return c.Next()
}
policy, ok := rules[route.Name]
if !ok {
log.Printf("authz: no policy for %s %s (name %q), denying",
route.Method, route.Path, route.Name)
return fiber.ErrForbidden
}
if policy.Public {
return c.Next()
}
user, ok := lookup(c.Bearer())
if !ok {
c.Set(fiber.HeaderWWWAuthenticate, `Bearer realm="notes"`)
return fiber.ErrUnauthorized
}
if !policy.allows(user) {
return fiber.ErrForbidden
}
c.SetLocal(userKey{}, user)
return c.Next()
}
}
// checkPolicies fails when a route has no policy, a policy has no route,
// or one name is shared by routes on different paths.
func checkPolicies(app *fiber.App, rules map[string]Policy) error {
// GetRoutes lists the routes of mounted sub-apps and the automatic
// HEAD routes only once the app is prepared for serving. Handler
// does that preparation now; Listen repeats it later.
app.Handler()
// A set, because GET and its automatic HEAD route report the same thing.
problems := map[string]bool{}
pathOf := map[string]string{} // route name -> first path that uses it
for _, r := range app.GetRoutes(true) {
if _, ok := rules[r.Name]; !ok {
msg := fmt.Sprintf("%s has no policy (name %q)", r.Path, r.Name)
problems[msg] = true
continue
}
// Methods of one path (Add, All, automatic HEAD) may share a
// name. Different paths must not, or one inherits the other's rule.
if first, ok := pathOf[r.Name]; !ok {
pathOf[r.Name] = r.Path
} else if first != r.Path {
problems[fmt.Sprintf("name %q is used by %s and %s",
r.Name, first, r.Path)] = true
}
}
for name := range rules {
if _, ok := pathOf[name]; !ok {
problems[fmt.Sprintf("policy %q matches no route", name)] = true
}
}
if len(problems) > 0 {
list := slices.Sorted(maps.Keys(problems))
return errors.New("authz: " + strings.Join(list, "; "))
}
return nil
}
type Note struct {
ID string `json:"id"`
Owner string `json:"owner"`
Text string `json:"text"`
}
type store struct {
mu sync.Mutex
next int
notes map[string]Note
}
func newApp(tokens map[string]User) *fiber.App {
app := fiber.New()
db := &store{notes: map[string]Note{}}
app.Use(authorize(policies, func(token string) (User, bool) {
u, ok := tokens[token]
return u, ok && token != ""
}))
app.Get("/healthz", func(c fiber.Ctx) error {
return c.SendString("ok")
}).Name("health")
api := app.Group("/api").Name("api.")
api.Get("/notes", func(c fiber.Ctx) error {
me := currentUser(c)
db.mu.Lock()
defer db.mu.Unlock()
mine := []Note{}
for _, n := range db.notes {
if n.Owner == me.ID {
mine = append(mine, n)
}
}
sort.Slice(mine, func(i, j int) bool { return mine[i].ID < mine[j].ID })
return c.JSON(mine)
}).Name("notes.list")
api.Get("/notes/:id", func(c fiber.Ctx) error {
me := currentUser(c)
db.mu.Lock()
n, ok := db.notes[c.Params("id")]
db.mu.Unlock()
// Route policy said "authenticated". Whether this user may
// read this note is a question only the handler can answer.
if !ok || n.Owner != me.ID {
return fiber.ErrNotFound
}
return c.JSON(n)
}).Name("notes.show")
api.Post("/notes", func(c fiber.Ctx) error {
var in struct {
Text string `json:"text"`
}
if err := c.Bind().JSON(&in); err != nil {
return fiber.ErrBadRequest
}
db.mu.Lock()
db.next++
n := Note{
ID: fmt.Sprintf("n%d", db.next),
Owner: currentUser(c).ID,
Text: in.Text,
}
db.notes[n.ID] = n
db.mu.Unlock()
return c.Status(fiber.StatusCreated).JSON(n)
}).Name("notes.create")
api.Get("/admin/users", func(c fiber.Ctx) error {
ids := make([]string, 0, len(tokens))
for _, u := range tokens {
ids = append(ids, u.ID)
}
sort.Strings(ids)
return c.JSON(ids)
}).Name("admin.users")
return app
}
func main() {
tokens := map[string]User{
"alice-token": {ID: "alice", Roles: []string{"admin"}},
"bob-token": {ID: "bob"},
}
app := newApp(tokens)
if err := checkPolicies(app, policies); err != nil {
log.Fatal(err)
}
log.Fatal(app.Listen(":3000"))
}
The middleware does four things in order. It asks for the endpoint, and if
there is none, it calls c.Next() and lets the router produce its normal 404
or 405. It looks up the policy by name and denies with 403 if there is no
entry, logging the method, path and name so the person who added the route can
find it. Public routes pass without a token. Everything else needs a bearer
token, read with c.Bearer(), which returns the credentials of an
Authorization: Bearer ... header and an empty string for any other scheme. A
missing or unknown token is a 401 with a WWW-Authenticate header, a known
user without the right role is a 403.
c.Bearer() returns a view into the request buffer, like most Ctx getters.
That is fine for a map lookup. A lookup that caches tokens or keeps them past
the request has to copy the string first, for example with strings.Clone.
Registration order matters as much as the code. Fiber runs handlers in the
order they were registered, so app.Use(authorize(...)) has to come before the
first route. A route registered above it never passes through the middleware,
and checkPolicies cannot notice, because the route has a name and a policy.
Keep the app.Use call as the first thing newApp does after fiber.New.
The authenticated user ends up in the request locals with c.SetLocal, keyed by
an unexported struct type so no other package can collide with it. Handlers read
it back with the generic fiber.Locals[User]. Since a handler only runs after
authorize approved the request, protected handlers always find a user. On a
public route nobody stored one, and currentUser returns the zero User with
an empty ID, so do not call it there.
checkPolicies walks app.GetRoutes(true), where true filters out app.Use
registrations, and collects every problem instead of stopping at the first, so
one failed start tells you everything. Besides missing entries it looks for a
name that two different paths share. Fiber does not reject duplicate names at
registration, and the middleware only sees the name, so a sensitive route that
reuses the name of a public one would quietly run under the public rule. Several
methods on the same path, as with Add, All or the automatic HEAD route,
may share a name, because they are one endpoint as far as the policy is
concerned. It calls app.Handler() before reading
the routes, and that line is easy to drop by accident. Before the app is
prepared for serving, GetRoutes does not yet contain the routes of mounted
sub-apps or the automatic HEAD routes, so a sub-app route without a policy
would slip through, and a correct policy for one would be reported as matching
nothing. Handler runs the same preparation that Listen runs, and Listen
repeating it later did no harm in my tests. The check runs after all routes are
registered and before Listen. If you register routes in several packages, call
it at the point where all of them have been added.
Trying Itโ
Start the server with go run . and use curl from a second shell. The health
check is public:
curl -i http://localhost:3000/healthz
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
...
ok
The notes list needs a token, and the response says which scheme it expects:
curl -i http://localhost:3000/api/notes
HTTP/1.1 401 Unauthorized
Content-Type: text/plain; charset=utf-8
Www-Authenticate: Bearer realm="notes"
...
Unauthorized
Bob can create a note and read it back:
curl -s -X POST -H 'Authorization: Bearer bob-token' \
-H 'Content-Type: application/json' -d '{"text":"buy milk"}' \
http://localhost:3000/api/notes
curl -s -H 'Authorization: Bearer bob-token' http://localhost:3000/api/notes/n1
{"id":"n1","owner":"bob","text":"buy milk"}
{"id":"n1","owner":"bob","text":"buy milk"}
Bob is not an admin, so GET /api/admin/users answers 403 Forbidden for his
token and ["alice","bob"] for Alice's. A path that does not exist still gets
a 404 Not Found, and DELETE /api/notes/n1 gets
405 Method Not Allowed with Allow: GET, HEAD. Both come from the router,
because c.Endpoint() returned nil and the middleware stepped aside.
Now break the contract on purpose. Add app.Get("/debug/vars", ...) without a
name, and rename the admin policy to api.admin.user with a typo. The process
refuses to start and lists all three problems:
authz: /api/admin/users has no policy (name "api.admin.users"); /debug/vars has no policy (name ""); policy "api.admin.user" matches no route
The stale policy line matters more than it looks. A policy that matches no route usually means a route was renamed, and the renamed route is then the one without a rule.
Route Policy Is Not Object Policyโ
Alice is an admin and Bob's note is n1, yet
GET /api/notes/n1 with Alice's token returns 404. The route policy only said
"authenticated", and Alice is. The question "may this user read this note" is
not about the route at all, it is about the record, and the middleware cannot
answer it because it does not load records. That check stays in the handler,
which compares n.Owner with the current user. I return 404 instead of 403
there so the API does not confirm that a note with that ID exists.
The policy table answers "who may call this kind of endpoint". It does not replace the ownership check that OWASP lists right after deny by default, and it is easy to believe it does once the table looks complete.
Pin It Down in a Testโ
The startup check catches drift on the next deploy. A test catches it in the
pull request that causes it. The test file below runs checkPolicies against
the real app, rejects a name that was copied onto a second path, proves that an
unnamed route gets a 403 even when the caller is an admin, and walks an access
matrix, including the automatic HEAD routes and the 404 and 405 paths.
package main
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v3"
)
var testTokens = map[string]User{
"alice-token": {ID: "alice", Roles: []string{"admin"}},
"bob-token": {ID: "bob"},
}
func TestEveryRouteHasAPolicy(t *testing.T) {
if err := checkPolicies(newApp(testTokens), policies); err != nil {
t.Fatal(err)
}
}
func TestSharedNameIsReported(t *testing.T) {
app := newApp(testTokens)
// Copy-pasted name: without the check, the export would be public.
app.Get("/api/export", func(c fiber.Ctx) error {
return c.SendString("all notes")
}).Name("health")
if err := checkPolicies(app, policies); err == nil {
t.Fatal("checkPolicies accepted one name on two paths")
}
}
func TestUnnamedRouteIsRejected(t *testing.T) {
app := newApp(testTokens)
app.Get("/debug/vars", func(c fiber.Ctx) error {
return c.SendString("secrets")
})
if err := checkPolicies(app, policies); err == nil {
t.Fatal("checkPolicies accepted a route without a policy")
}
req := httptest.NewRequest(http.MethodGet, "/debug/vars", nil)
req.Header.Set("Authorization", "Bearer alice-token")
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != fiber.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
}
func TestAccessMatrix(t *testing.T) {
app := newApp(testTokens)
tests := []struct {
method, path, token string
want int
}{
{"GET", "/healthz", "", 200},
{"HEAD", "/healthz", "", 200},
{"GET", "/api/notes", "", 401},
{"HEAD", "/api/notes", "", 401},
{"GET", "/api/notes", "bob-token", 200},
{"GET", "/api/admin/users", "bob-token", 403},
{"GET", "/api/admin/users", "alice-token", 200},
{"GET", "/api/nope", "", 404},
{"DELETE", "/api/notes/n1", "", 405},
}
for _, tt := range tests {
req := httptest.NewRequest(tt.method, tt.path, nil)
if tt.token != "" {
req.Header.Set("Authorization", "Bearer "+tt.token)
}
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != tt.want {
t.Errorf("%s %s (%q) = %d, want %d",
tt.method, tt.path, tt.token, resp.StatusCode, tt.want)
}
}
}
The HEAD rows guard against a quiet change. Fiber generates a HEAD route for
every GET route, and the generated route carries the same name as the GET
route, so HEAD /api/notes gets the same 401 as GET. If that ever changed,
an unauthenticated HEAD would hit the deny-by-default branch and return 403,
and this test would tell you.
Requests That Never Reach an Endpointโ
c.Endpoint() only knows about endpoints, which are routes registered with
Get, Post, Add, All and the other method helpers. Anything registered
with app.Use is middleware to the router, even when it answers the request
itself. The most common example is the static middleware.
I registered app.Use("/assets", static.New("./public")) and requested
/assets/a.txt: c.Endpoint() returned nil, the authorization middleware
treated it like a 404, called c.Next(), and the static middleware served the
file with 200. No policy was consulted.
For public assets that is harmless. It becomes a hole when someone serves
exports or uploads that way, or mounts a handler on a prefix with app.Use
because it was the shortest way to catch every subpath. The fix is to register
such handlers as routes:
app.Get("/assets/*", static.New("./public", static.Config{
// A miss must end here. By default static calls c.Next() and the
// request falls through to whatever route matches next.
NotFoundHandler: func(c fiber.Ctx) error {
return fiber.ErrNotFound
},
})).Name("assets")
This serves the same files, c.Endpoint() returns the route with the name
assets, and the table can mark it public or not. The NotFoundHandler is not
decoration. Without it, the static middleware calls c.Next() when a file does
not exist, and the router continues with the next matching route. I tested that
with assets marked public and an admin-only GET /assets/export registered
after it: an anonymous request to /assets/export was authorized as assets,
the static middleware found no file, fell through, and the export handler
answered with 200. With the NotFoundHandler the same request gets a 404.
The general rule behind it: authorize checks the endpoint the router reaches
first, once. Any handler that calls c.Next() to pass the request on to a later
route, or that changes the routing target after the check, hands the request to
an endpoint nobody authorized. If you need that kind of fall-through, the second
endpoint has to check again, for example with route-level middleware. The
GetRoutes(true) check will not warn you about app.Use handlers, because it
filters them out on purpose, so this is a rule for code review: anything that
sends a response is a route, not a Use.
Mounted sub-apps behave better. With app.Use("/v2", sub), c.Endpoint()
returns the flattened route, for example /v2/items/:id, and GetRoutes lists
it once the app has been prepared, which is why checkPolicies calls
app.Handler() first. The name, however, is the one given inside the sub-app,
without any prefix from the mount, so two sub-apps can both have a route called
items.show. checkPolicies reports that, because the two routes have
different paths, but it is better not to get there. Give each sub-app's names a
prefix of its own, either by hand or through a group inside the sub-app.
Details That Biteโ
Several Methods, One Nameโ
app.Add([]string{"GET", "POST"}, "/multi", h).Name("multi") names both
routes multi, and they share one policy. That is fine when both methods need
the same rule. When reading and writing need different rules, register them
separately and give them separate names. If you prefer to keep them together,
key the table by method and name instead of name alone. The middleware has
route.Method right there. That only splits the methods of one path, though.
It does not help when two different paths carry the same name, which is why
checkPolicies treats that as an error.
Naming a Group Too Lateโ
Group.Name only sets the group prefix while the group has no routes. Called
after the first route, it renames the latest route instead. Name the group in
the same line that creates it, as in the example, and the problem never comes
up.
Something Earlier Can Still Answerโ
The route c.Endpoint() returns is the one the router would reach, not a
promise that it runs. A middleware registered before authorize can end the
request on its own. That is what you want for CORS preflight requests, rate
limiting and the host check from the DNS rebinding
post, but it also means
nothing registered before authorize should hand out data. I put the
authorization middleware directly after the ones that only reject or annotate
requests.
Routes Added at Runtimeโ
Fiber can add and remove routes after start with RebuildTree and the
RemoveRoute family, which the routing guide recommends only for development.
checkPolicies runs once, so a route added later is not checked at startup.
The middleware still denies it until the table has an entry, which is the
behavior you want, but remember that the table itself is a plain map. If you
change it at runtime too, guard it with a lock or swap it atomically.
The Second Router Scanโ
c.Endpoint() scans the remaining routes of the request's tree bucket, so a
global middleware that calls it pays for a second lookup on every request. The
Fiber docs call that cheap when routes are spread over many prefixes and
noticeable when a hundred or more share one, as with everything under
/api/v1. I wanted to know what "noticeable" means, so I wrote a small
benchmark. It registers n routes of the form /api/v1/rN/:id, puts a global
middleware in front that either only calls c.Next() or calls c.Endpoint()
first, and requests the last route, which is the worst case for the scan:
package bench
import (
"fmt"
"testing"
"github.com/gofiber/fiber/v3"
"github.com/valyala/fasthttp"
)
func build(n int, lookAhead bool) fasthttp.RequestHandler {
app := fiber.New()
app.Use(func(c fiber.Ctx) error {
if lookAhead {
_ = c.Endpoint() // what the authorize middleware does
}
return c.Next()
})
for i := 0; i < n; i++ {
app.Get(fmt.Sprintf("/api/v1/r%d/:id", i), func(c fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}).Name(fmt.Sprintf("r%d", i))
}
return app.Handler()
}
func BenchmarkEndpoint(b *testing.B) {
for _, n := range []int{10, 150} {
for _, la := range []bool{false, true} {
h := build(n, la)
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("GET")
ctx.Request.SetRequestURI(fmt.Sprintf("/api/v1/r%d/42", n-1))
name := fmt.Sprintf("routes=%d/endpoint=%v", n, la)
b.Run(name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
h(ctx)
}
})
if ctx.Response.StatusCode() != 204 {
b.Fatalf("status %d", ctx.Response.StatusCode())
}
}
}
}
On a shared four-core cloud VM (Intel Xeon at 2.8 GHz, Go 1.26.0, Fiber v3.6.1,
two runs of go test -bench . -count 5), the numbers were noisy but the shape
was clear. With 10 routes a request took about 240 to 300 ns without the
look-ahead and about 380 to 610 ns with it. With 150 routes under the same
prefix it went from about 530 to 670 ns to about 2.8 to 4.8 microseconds.
Neither variant allocated. A few hundred nanoseconds is nothing next to a
database query, but with a large flat API the look-ahead can cost more than the
routing itself. If your routes are spread over several prefixes, you are closer
to the first numbers. Run the benchmark against your own route table before you
decide, and remember that group middleware costs nothing extra.
When Group Middleware Is Enoughโ
Not every app needs a policy table. If you have five routes, two groups and one
person who writes all of them, api.Use(requireLogin) is clear and fast, and a
table adds a layer that someone has to keep in sync. The table pays off when
many people add routes, when groups are reorganized, or when an auditor asks
for a list of every endpoint and who may call it. The policy map is that list,
and the startup check makes sure it matches the routes.
You also do not have to choose one or the other everywhere. Group middleware can keep doing work that belongs to a group, such as loading a tenant, while the table makes the final access decision. What I would avoid is splitting the access decision itself between both, because then neither place tells the whole story.
Wrapping Upโ
c.Endpoint() lets one middleware see the route a request is headed for, so
access rules can live in one table instead of in the placement of routes. Deny
every name the table does not know, compare the table with GetRoutes at
startup and in a test, keep ownership checks in the handlers, and register
anything that sends data as a route rather than with app.Use.
Internal Referencesโ
- Ctx: Endpoint, Ctx: Route and Ctx: Matched
- Ctx: Bearer, Ctx: SetLocal and Ctx: Locals
- App: Name and App: GetRoutes
- Routing Guide: Automatic HEAD routes
- Static Middleware
- Localhost Is Not a Boundary
- Write Your Own Middleware
- Fiber v3.6.0 release (Endpoint, Bearer, SetLocal, Go 1.26)
- Fiber v3.6.1 release
- Add Endpoint() for middleware look-ahead (#4601)
- OWASP Top 10 2025: A01 Broken Access Control
