Iris has exactly one handler type, func(ctx iris.Context), and middleware
is nothing more than a handler that decides whether the rest of the chain
runs. That single decision (call ctx.Next() or stop) powers
authentication guards, request logging, panic recovery and everything in
between. This chapter explains the handler chain, the registration methods
and their exact scopes, how to write your own middleware, and the ordering
rules that determine what runs when. The catalog of ready-made middleware
packages lives in Chapter 12; this chapter is
about the machinery they all share.
Background
Every non-trivial web service repeats certain work on every request: write an access log line, check credentials, attach an ID for tracing, reject oversized bodies, recover from crashes. These are cross-cutting concerns: work that belongs to no single endpoint but applies across all of them. Copying that work into every handler multiplies it by the number of endpoints and guarantees the copies drift apart; the login check that was fixed in nine handlers and forgotten in the tenth is a classic security hole.
Middleware is the pipeline answer to that problem. Instead of each handler doing everything, the framework assembles a chain of small functions around every request; each one does its piece and passes control onward. Think of the layers of an onion: the request travels inward through logging, authentication and limits before reaching the handler at the core, and the response travels back out through the same layers in reverse, which is why a timing middleware can note the clock on the way in and compute the duration on the way out. You write each concern once, register it once, and every route beneath it is covered.
The chain has a second power: any layer can stop the request. An authentication guard that finds no valid token writes a 401 and simply does not pass control on: the handler never runs, and neither does any code that assumed a valid user. Ordering follows from the same logic: the cheap protective layers (rate limits, body caps) belong outside the expensive ones.
One distinction in this chapter deserves a plain definition up front.
Routing is the step where the framework matches a request’s method and
path against the registered routes to pick a handler; the component
doing the matching is called the mux (short for multiplexer). “Before
routing” middleware (UseRouter) runs before that lookup, so it sees
every request, including those that match nothing and will end as a
404. “Per route” middleware (Use and friends) is baked into each
matched route’s chain, so it runs only when routing found a handler.
Practically: access logs and request IDs belong before routing, because
a request that matches nothing still deserves a log line; a guard that
loads the current user belongs on the routes that need a user.
Handlers and the Chain
Every request runs through a chain of handlers assembled for its route.
Each handler receives the same iris.Context and controls the chain with
three moves:
func example(ctx iris.Context) {
// 1. Work before the rest of the chain.
ctx.Next() // 2. Run the remaining handlers.
// 3. Work after the chain below has completed
// (post-processing: timing, logging, metrics).
}Not calling ctx.Next() ends the chain quietly: the following handlers
simply never run. ctx.StopExecution() goes further: it marks the
context as stopped, so any later Next call anywhere in the chain becomes
a no-op. ctx.IsStopped() reports that state. The StopWith* shortcuts
combine stopping with a response:
ctx.StopWithStatus(code): status code only.ctx.StopWithText(code, text): plain text body.ctx.StopWithJSON(code, value): JSON body.ctx.StopWithError(code, err): records the error and writes its message, or only the status when the error is private.ctx.StopWithPlainError(code, err): records the error without writing a body, leaving rendering to an error handler such as therest/errorspipeline (Chapter 9).
A “middleware” is a handler registered before others: by position in the route line, or through one of the registration methods below. There is no separate middleware interface to implement.
The Registration Family
Where you register a handler determines which requests it sees. The
methods, with their exact scopes as implemented in
core/router/router_builder.go:
| Call | Runs for |
|---|---|
app.UseRouter(h) | Every request, before routing, including 404/405 |
r.UseRouter(h) (child) | Every request under the child’s path prefix or subdomain, before routing |
app.Use(h) (root) | Every matched route, plus the 404 and 405 chains |
r.Use(h) (child) | Matched routes of that child and its children only |
app.UseRelevant(h) | Matched routes only, never 404/405 |
app.UseOnce(h) | Like root Use, but insert-or-replace |
app.UseRelevantOnce(h) | Like UseRelevant, but insert-or-replace |
One caveat on the 405 rows: the no-method chain only runs when you set
Router.FireMethodNotAllowed, which defaults to false. Until you do, a
wrong-method request goes to the 404 chain instead.
UseRouter: before routing
UseRouter handlers run before the mux looks at the path. They fire for
every request (matched, not found, or method not allowed), which makes
them the right place for access logging, request IDs, CORS, compression
and rate limiting. Because they run first, they also hold veto power: a
UseRouter handler that writes a response without calling ctx.Next()
prevents routing from ever happening.
CORS (Cross-Origin Resource Sharing) shows why “every request” matters.
It is the browser mechanism that decides whether a page on one site may
call your API on another; before certain cross-site requests the browser
sends an automatic OPTIONS “preflight” request asking for permission.
A preflight often matches no registered route, so a CORS handler
attached per route would never see it. Registered with UseRouter, it
answers the preflight before routing can 404 it.
Called on a child router, UseRouter still registers at the top level but
wraps the handler in a prefix (and subdomain) check, so it fires only for
requests under that child’s path, still before routing.
Use: root versus child
The root-versus-child difference in Use is the one that surprises
people. On the root application, Use handlers join every route’s chain
and are prepended to the no-route (404) and no-method (405) chains, so
they observe requests that match nothing at all. On a child router, Use
delegates to UseRelevant: the handlers join only the routes registered
under that child.
package main
import (
"github.com/kataras/iris/v14"
)
func main() {
app := iris.New()
app.UseRouter(func(ctx iris.Context) {
// Before routing: fires for "/", "/admin/panel"
// and any 404 or 405 request.
ctx.Next()
})
app.Use(func(ctx iris.Context) {
// Root Use: all matched routes, plus the 404/405 chains.
ctx.Next()
})
admin := app.Router("/admin")
admin.Use(func(ctx iris.Context) {
// Child Use: only routes registered under /admin.
ctx.Next()
})
admin.Get("/panel", func(ctx iris.Context) {
ctx.Text("panel")
})
app.Get("/", func(ctx iris.Context) {
ctx.Text("index")
})
app.Listen(":8080")
}When you want root middleware that skips 404s, say so explicitly with
app.UseRelevant(h). Note that UseRelevant and UseRelevantOnce are
methods of the application; the iris.Router interface that child groups
and Routing(r iris.Router) controllers receive exposes Use, UseOnce
and UseRouter. A child’s Use is already matched-only.
UseOnce and UseRelevantOnce: upsert
UseOnce and UseRelevantOnce insert a handler or, if a handler with the
same identity is already registered, replace it in place. Identity is the
function’s resolved name, so re-running setup code does not stack
duplicates: the second registration replaces the first. The same rule
means two closures produced by the same factory function share a name and
therefore replace each other; register factory-produced middleware with
plain Use when you need several instances.
WrapRouter: below everything
For integrations that need raw net/http semantics, app.WrapRouter
wraps the entire router:
app.WrapRouter(func(w http.ResponseWriter, r *http.Request,
router http.HandlerFunc) {
// Runs before the Iris context even exists.
router(w, r) // call the wrapped router (or don't).
})Wrappers registered with WrapRouter execute last-registered-first;
app.AddRouterWrapper is the first-registered-first variant. Prefer
UseRouter unless a third-party package hands you an http.Handler
wrapper you cannot avoid.
Guards and Per-Route Middleware
Handlers listed before the final one in a route registration act as middleware for that route alone:
app.Get("/reports", requireAuth, reportsHandler)In controller-style APIs this becomes the guard idiom: guards are methods
on the API struct, so they see the same injected dependencies as the
handlers. A group guard registered with r.Use protects every route of
the controller; a positional guard between path and handler protects
individual routes. From the Bookshelf application of the framework’s
TUTORIAL.md:
// BooksAPI serves /shelves/{shelf_id:uuid}/books.
func (api *BooksAPI) Routing(r iris.Router) {
// Group guard: every book route needs an existing shelf.
r.Use(api.requireShelf)
r.Get("/", api.list)
// Positional guard: writes additionally need an unlocked shelf.
r.Post("/", api.requireUnlocked, api.create)
r.Put("/{id:uuid}", api.requireUnlocked, api.update)
r.Delete("/{id:uuid}", api.requireUnlocked, api.delete)
}
// requireUnlocked rejects writes against a locked shelf. The shelf was
// loaded and cached by requireShelf, the group guard above.
func (api *BooksAPI) requireUnlocked(ctx iris.Context) {
shelf, _ := ctx.ValueAs[library.Shelf](shelfContextKey)
if shelf.Locked {
errors.PermissionDenied.Detail(ctx, "shelf is locked",
"unlock the shelf before modifying its books")
return // the errors senders stop the request.
}
ctx.Next()
}The contract is plain: call ctx.Next() to continue, or write a response
(the rest/errors senders stop the request for you) to end it. The full
controller, including the requireShelf guard that loads and caches the
shelf, is developed in Chapter 4.
Writing Custom Middleware
A timing middleware
Go closures make middleware configuration natural. A closure is a function value that keeps access to the variables of the scope where it was created, so a returned handler can still read the arguments of the function that built it, long after that function returned.
Configurable middleware is a factory function returning a handler; the closure captures the configuration:
package main
import (
"time"
"github.com/kataras/iris/v14"
)
// SlowRequestLogger warns about requests slower than "threshold".
func SlowRequestLogger(threshold time.Duration) iris.Handler {
return func(ctx iris.Context) {
start := time.Now()
ctx.Next()
if d := time.Since(start); d > threshold {
ctx.Application().Logger().Warnf(
"slow request: %s %s took %s",
ctx.Method(), ctx.Path(), d)
}
}
}
func main() {
app := iris.New()
app.UseRouter(SlowRequestLogger(500 * time.Millisecond))
app.Get("/", func(ctx iris.Context) {
ctx.Text("ok")
})
app.Listen(":8080")
}One caveat for post-processing: by the time ctx.Next() returns, the
response body has usually been written, and headers cannot change after
the first write. Code after Next should log, record metrics or update
state. Set response headers before calling Next.
Request IDs with middleware/requestid
Correlating a response with its server-side logs is the classic middleware
job, and Iris ships it as middleware/requestid:
package main
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/middleware/requestid"
)
func main() {
app := iris.New()
// Assign an ID to every request, even 404s.
app.UseRouter(requestid.New())
app.Get("/", func(ctx iris.Context) {
ctx.Text("request id: " + requestid.Get(ctx))
})
app.Listen(":8080")
}The default generator reuses a client-supplied X-Request-Id header or
generates a UUID, sets the response header and stores the ID on the
context (ctx.ID() returns it, requestid.Get(ctx) as a string).
Accepting the client’s ID is convenient for tracing a call through several services, but it also means the value lands in your logs exactly as the client wrote it. Log injection is the abuse of that trust: input crafted with newlines or control characters to forge or corrupt log entries. When clients must not be able to choose or forge IDs, pass the server-side generator:
app.UseRouter(requestid.New(requestid.ServerOnlyGenerator))Conditional Middleware
iris.NewConditionalHandler combines a filter with one or more handlers
into a single registerable handler. The filter is an
iris.Filter, func(iris.Context) bool. When it returns true, the given
handlers are spliced into the current request’s chain; when it returns
false, the chain simply continues without them. The wrapped handlers must
behave like middleware and call ctx.Next().
package main
import (
"github.com/kataras/iris/v14"
)
func auditLog(ctx iris.Context) {
ctx.Application().Logger().Infof("audit: %s %s",
ctx.Method(), ctx.Path())
ctx.Next()
}
func main() {
app := iris.New()
// Audit only mutating requests.
app.Use(iris.NewConditionalHandler(func(ctx iris.Context) bool {
return ctx.Method() != iris.MethodGet
}, auditLog))
app.Get("/items", func(ctx iris.Context) {
ctx.Text("read")
})
app.Post("/items", func(ctx iris.Context) {
ctx.Text("write")
})
app.Listen(":8080")
}For coarser conditions you rarely need it: prefer attaching middleware to the child router or subdomain whose requests it concerns, and let the routing structure do the filtering.
Error-Returning Middleware
Middleware that performs I/O (token validation, database lookups) reads
better when it can return an error instead of hand-writing a response.
errors.FuncMiddleware from rest/errors converts a
func(ctx iris.Context) error into a standard handler: a nil return calls
ctx.Next(), a non-nil return flows through the central error pipeline
(mapping, logging, JSON rendering) and stops the request.
package main
import (
"fmt"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
)
// Accounts is the store the guard consults; in production it is a
// database.
type Accounts struct{ active map[string]bool }
func (a *Accounts) Active(ctx iris.Context, id string) (bool, error) {
return a.active[id], nil
}
var accounts = &Accounts{active: map[string]bool{"acme": true}}
// requireActiveSubscription returns an error instead of writing a
// response; FuncMiddleware below turns it into an ordinary handler.
func requireActiveSubscription(ctx iris.Context) error {
account := ctx.Params().GetString("account")
active, err := accounts.Active(ctx, account)
if err != nil {
return err // the store is down: the central map makes it a 500.
}
if !active {
return errors.FailedPrecondition.Wrap(
fmt.Errorf("account %q has no active subscription", account))
}
return nil // continue the chain.
}
func main() {
app := iris.New()
data := app.Router("/{account:string}",
errors.FuncMiddleware(requireActiveSubscription))
data.Get("/data", func(ctx iris.Context) {
ctx.JSON(iris.Map{"data": "value"})
})
app.Listen(":8080")
}The check is a business rule, which is what this shape is for: it does
I/O, it can fail two ways, and only your code knows it. Authentication is
not in that class. A shared secret presented by a machine caller is
apikey.New from middleware/apikey, covered in
Chapter 12, which reads the header,
compares in constant time, renders the 401 and names the caller for you.
Reading X-API-Key yourself in a middleware like the one above is the
version to stop writing.
errors.FuncHandler is the terminal-handler counterpart (no Next on
success), and errors.NewRouter wraps a whole router so every route and
middleware registration accepts error-returning functions; see
Chapter 4 for the wrapper and
Chapter 9 for the error map that gives returned
errors their status codes.
What the Builder Already Registers
Applications assembled with iris.NewBuilder come with a middleware
baseline, installed once when the chain reaches
RouterMiddlewares/Middlewares. Do not register these again:
- The options handler for your
Errors(...)options, viaUseRouter, so every sender andHandleErrorcall uses your map, logger and renderer. errors.Handler, viaUseRouterafter compression, and as the 404 and 405 handler when the application is built. A bare failure status, an unknown path and (withFireMethodNotAllowed) a wrong method render as consistent JSON. To answer 404 or 405 yourself, registerHandleNoRoute/HandleNoMethodfrom aConfiguratorpassed toNewBuilder; the builder fills the two slots only when they are empty.- Panic recovery:
recovery.WithErrorHandler(errors.Internal.LogErr)followed byrecovery.Handler, skipped if a handler namediris.recoveryoriris.errors.recoveris already present. - The path-parameter type error handler: a malformed
{id:uuid}renders 400INVALID_ARGUMENTinstead of a bare 404. - The context error handler for failed writes such as
ctx.JSONerrors. - A
Server: Irisresponse header, plusX-Developerwhen configured. compress.SafewhenCompression(true)was selected, skipped if a handler namediris.compressionexists.- The rate-limit exceed handler, bound to the JSON error renderer.
Your own additions slot into the same two steps:
RouterMiddlewares(...) registers through UseRouter and
Middlewares(...) through Use, both accepting dependency-injected
factories as well as plain handlers; details in
Chapter 3.
A panic is Go’s runtime failure mode: a nil pointer dereference, an
out-of-range index or an explicit panic() call abruptly unwinds the
goroutine (the lightweight thread) serving the request. Left
unrecovered, the client gets a dropped connection with no response and
the failure surfaces only as a stack trace in the server log. Recovery
middleware wraps the chain in Go’s recover() mechanism, so one broken
code path yields a logged error and a clean 500 instead.
With iris.New() none of this exists, and recovery is the one piece you
should always add yourself:
package main
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/middleware/recovery"
)
func main() {
app := iris.New()
app.UseRouter(recovery.Handler)
app.Get("/", func(ctx iris.Context) {
panic("boom") // recovered: logged, 500 returned.
})
app.Listen(":8080")
}To customize what a recovered panic returns, register a per-request error
handler before recovery.Handler:
app.UseRouter(recovery.WithErrorHandler(
func(ctx iris.Context, err error) {
// Logs the panic and answers the canonical INTERNAL body; the
// panic value itself never reaches the client.
errors.Internal.LogErr(ctx, err)
}), recovery.Handler)Ordering Rules
Three rules govern execution order, all verified by the router’s own test
suite (core/router/router_handlers_order_test.go):
1. Layers run outermost-first. For a matched route:
2. Within a layer, registration order wins. Two Use calls run in the
order they were made; handlers listed in one call run left to right. Code
placed after ctx.Next() runs in reverse order on the way out, like
deferred calls: the first middleware’s post-processing executes last, so
a timing middleware registered first measures everything beneath it.
3. A route’s chain is assembled at registration time. When Get,
Post or Handle runs, the router snapshots the Use/UseRelevant
handlers registered so far and bakes them into the route. Middleware
registered afterwards does not reach earlier routes, so register
middleware first, routes second. The builder’s compiler-enforced step
order (Middlewares before API) exists precisely to make this mistake
impossible; with iris.New() the discipline is yours.
UseRouter and WrapRouter are the exception to rule 3: they belong to
the router itself rather than to individual routes, and take effect for
all requests once the application is built.
Summary
- A middleware is an ordinary
iris.Handlerthat callsctx.Next()to continue or stops the chain: quietly by returning, or firmly withctx.StopExecution()and theStopWith*shortcuts. - Scope is chosen by registration method:
UseRouterruns before routing for every request; rootUsecovers matched routes plus the 404/405 chains; childUse(andUseRelevant) covers matched routes only;UseOnce/UseRelevantOnceinsert-or-replace by handler name. - Positional handlers guard single routes; guard methods on API structs share the controller’s injected dependencies.
iris.NewConditionalHandlergates middleware on a per-request filter;errors.FuncMiddlewarelets middleware return errors into the central error pipeline.- Builder-assembled applications already include recovery, error
rendering, default headers and optional compression. Register only
what you add on top. With
iris.New(), start withapp.UseRouter(recovery.Handler). - Layers run outermost-first, registration order wins within a layer, and route chains are frozen at route registration: middleware first, routes second.
Further Reading
- pkg.go.dev: net/http Handler:
the standard library interface
WrapRouterexposes underneath Iris. - Go blog: Defer, Panic, and Recover: the language mechanics behind recovery middleware.
- A Tour of Go: Function closures: the feature configurable middleware factories are built on.
- MDN: Cross-Origin Resource Sharing (CORS): why preflight requests exist and which headers answer them.
- OWASP: Logging Cheat Sheet: what to log, what never to log, and injection-safe log handling.