Chapter 12

Built-in Middleware

The 31 middleware packages that ship in-tree: CORS, rate limiting, recovery, compression, access logging, sessions, API keys, JWT, request IDs and more.

Iris ships thirty-one middleware packages under github.com/kataras/iris/v14/middleware/. Each one is a plain iris.Handler (or a small type that produces one), so they compose with your own handlers without adapters. This chapter is the tour: a catalog of everything that exists, what the iris.NewBuilder chain already installs for you, and a focused section with a working example for each middleware you are likely to reach for first. Topics that deserve a whole chapter get only a brief entry here with a pointer: authentication in Chapter 10, caching strategies in Chapter 14, i18n in Chapter 15 and observability in Chapter 17.

Background

Middleware, introduced in Chapter 2, is a handler that runs before or around your route handler. Every request flows through a chain: each middleware can inspect the request, attach data for later handlers, modify the response, pass control on with ctx.Next(), or stop the chain entirely. Cross-cutting concerns (the jobs every route needs but no route wants to implement) live naturally in this chain, written once and registered wherever they apply.

Each middleware family in this chapter exists because of a concrete problem:

  • CORS: browsers deliberately block a page on one site from calling an API on another until the API states, through response headers, which foreign origins it accepts.
  • Rate limiting: any public endpoint eventually gets hammered (by brute-force login scripts, scrapers or a buggy client stuck in a retry loop), so requests per client must be throttled.
  • Recovery: one panicking handler would otherwise tear down its request with a blank response; crash containment turns the panic into a controlled, logged 500.
  • Compression and body limits: text responses shrink several-fold when compressed, saving bandwidth on every reply. In the other direction, a client can send an arbitrarily large body and exhaust your memory unless a cap rejects it early.
  • Access logging: when something goes wrong in production, the audit trail (who requested what, when, and how it ended) is the record you reconstruct events from.
  • Sessions and tokens: HTTP is stateless, the server forgets each client between requests, so remembering “who is this” takes deliberate machinery.

“Batteries included” means these tools ship inside the framework rather than as third-party add-ons you assemble yourself. That saves more than typing: every package here is versioned with Iris, tested against the same context and router, and upgraded in lock-step, so the classic integration risks disappear: an adapter written for an older API, two libraries disagreeing about who writes headers first, a security default one package sets and another silently undoes. You can still plug in anything from the net/http ecosystem (see Adapting net/http Middleware); you just rarely need to.

The Middleware Catalog

Every package lives under github.com/kataras/iris/v14/middleware/<name>.

MiddlewarePackagePurposeConstructor form
Access logaccesslogStructured request/response loggingaccesslog.File(path) or accesslog.New(w), then .Handler
API keyapikeyShared-secret authentication for machine callersapikey.New(apikey.Options{Keys: ...})
Basic authbasicauthHTTP Basic Authenticationbasicauth.Default(users), basicauth.New(Options[U]), basicauth.Load(file)
Body limitbodylimitCap request body size (413 on excess)bodylimit.New(2 * iris.MB)
CachecacheETag, Cache-Control and friendscache.New(cache.Options{...})
CompresscompressResponse compression negotiationcompress.Handler
CORScorsCross-Origin Resource Sharingcors.New()....Handler()
CountercounterRequest counting with notificationscounter.RequestCounter(counter.NewCounter[uint64]())
GeolocationgeolocationIP-based geolocation lookupgeolocation.New(geolocation.Options{...})
gRPCgrpcServe a gRPC server through Irisgrpc.New(grpcServer)
hCaptchahcaptchahCaptcha challenge verificationhcaptcha.New(hcaptcha.Options{Secret: ...})
HTTP costhttpcostPer-request cost (time, RAM, CPU)httpcost.New(httpcost.DefaultOptions)
i18ni18nTranslations and language detectioni18n.NewHandler(locales, i18n.HandlerOptions{...})
IP accessipaccessIP allowlist and blocklistipaccess.New(allowed, blocked)
JWTjwtToken signing and verificationjwt.NewVerifier(alg, key).Verify(claims)
Method overridemethodoverridePUT/DELETE from HTML formsmethodoverride.New(methodoverride.Options{...})
Mod revisionmodrevisionBuild and health info endpointmodrevision.New(modrevision.Options{...})
MonitormonitorProcess and OS statistics pagemonitor.New(monitor.Options{...})
pprofpprofGo profiling endpointsapp.Router("/debug/pprof", pprof.New())
RaterateToken-bucket rate limitingrate.Limit(limit, burst, rate.Options{...})
reCAPTCHArecaptchaGoogle reCAPTCHA verificationrecaptcha.New(recaptcha.Options{Secret: ...})
RecorderrecorderRecord request/response bodiesrecorder.Request handler, recorder.GetResponseRecorder(ctx)
RecoveryrecoveryPanic recoveryrecovery.Handler
ReferrerreferrerReferer header parsingreferrer.Handler
Request IDrequestidUnique per-request identifiersrequestid.New(generator...)
RewriterewriteRedirect and rewrite rulesrewrite.New(rewrite.Options{...}), rewrite.Load(file)
Server timingservertimingServer-Timing response headerservertiming.Handler, then Start/End
SessionssessionsTyped server-side sessionssessions.Handler(db, cookieName)
VersioningversioningAPI versioning by header/query/subdomainversioning.New(versioning.Options{...})
ViewviewTemplate engine integrationview.Handler(engine), then view.Render
WebSocketwebsocketWebSockets via neffoswebsocket.New(server, websocket.Options{})

One convention runs through that last column. A package with settings exposes an Options struct whose zero value is valid, and a New(opts Options) iris.Handler that takes it; a package with nothing to configure exposes a plain Handler instead, so there is no empty constructor to call. Where construction can fail, New returns (handler, error) and a Must companion panics instead, for the common case of wiring at start-up. The handful of packages that produce something other than one handler, accesslog and cors among them, say so in the first paragraph of their package documentation.

That is why there are no functional options anywhere here: no rate.PurgeEvery(...), no methodoverride.HeadersOnly(). Everything they used to set is a field on the package’s Options.

Registration Levels

Where you register a middleware decides which requests it sees:

Go
app := iris.New()

// Root-level Use: every request, including 404 and 405 responses.
app.Use(handler)

// Child-level Use: only routes matched under this group.
api := app.Router("/api")
api.Use(handler)

// UseRouter: runs before routing itself — static files, rewrites,
// CORS preflights and everything else pass through it.
app.UseRouter(handler)

As a rule of thumb: infrastructure that must see every byte (CORS, compression, access logging, rewriting, versioning, recovery) belongs to UseRouter; request-scoped behavior (rate limits, body limits, sessions, authentication, cache headers) belongs to Use on the group that needs it.

What the Builder Installs for You

If you assemble your application with iris.NewBuilder (Chapter 3), several middleware from this chapter are registered by the chain itself:

  • Recovery is installed unless one is already registered: recovery.WithErrorHandler(errors.Internal.LogErr) followed by recovery.Handler, so panics become logged 500 INTERNAL responses. The builder skips it when a handler named iris.recovery or iris.errors.recover is already on the router.
  • CORS: the AllowOrigin(originLine) step registers cors.New().HandleErrorFunc(errors.FailedPrecondition.Err). AllowOrigin(originLine).Handler(). The Cors(c) step at the same position takes a CORS value you built yourself, for the settings the origin line cannot reach; it is used as given, so set HandleErrorFunc to keep the structured error body.
  • Compression: Compression(true) only records the choice; the compress.Handler itself is registered later, by the first RouterMiddlewares or Middlewares call, and on the root rather than the prefix router.
  • Access logging: LogRequests(true) registers a tuned accesslog.AccessLog (JSON lines to access_log.json plus stdout) and closes it on shutdown; LogRequestsWith(ac) registers your own instance instead.
  • Health endpoint: Health(true, env, developer) registers modrevision.New at GET /health on the root, outside the prefix.
  • Rate exceed handler: the builder binds the rate middleware’s 429 response to the JSON error format of the rest/errors package.

Order matters here, and it is not the order of the list above. CORS and the access log are registered by their own builder steps, so they are already in the chain by the time the first RouterMiddlewares or Middlewares call installs the rest. That is why recovery does not wrap them, and why the access log sees the 500 that recovery produces:

What the builder puts in the pre-routing chain The builder's middleware lands in one ordered pre-routing chain: CORS and the access log first because their steps register them directly, then the four that the first RouterMiddlewares call installs, and the mux last. PRE-ROUTING CHAIN (USEROUTER): ONE ORDERED LIST, BEFORE ANY ROUTE MATCH cors.New()...AllowOrigin(line).Handler() AllowOrigin(line) or Cors(c), skipped when line is "none". Scope: the Prefix router accesslog.AccessLog.Handler LogRequests(true) or LogRequestsWith(ac); Close becomes a shutdown closer errors.Options.Handler() Errors(opts). Scope: the root recovery.Handler with WithErrorHandler(errors.Internal.LogErr), unless an iris.recover handler exists Server: Iris and X-Developer added by RouterMiddlewares() itself compress.Handler only with Compression(true), and on the ROOT, not the Prefix router your RouterMiddlewares(...) handlers Scope: the Prefix router the mux: subdomain, method, path appended by Build() as the chain's last handler AFTER A ROUTE MATCHES your Middlewares(...) handlers Use on the Prefix router: matched routes only, never the 404 or 405 chains API("/x", ...) route chains group Use, then positional guards, then handlers The last four entries of the pre-routing chain are registered by the first RouterMiddlewares() or Middlewares() call, which is why they run after CORS and the access log. Health(true, ...) registers GET /health on the root, so it sits outside the prefix, and rate.RegisterExceedHandler only binds the 429 body shape: it installs no middleware.
The builder's middleware lands in one ordered pre-routing chain: CORS and the access log first because their steps register them directly, then the four that the first RouterMiddlewares call installs, and the mux last.

With the builder, you only reach into middleware/ for what the chain does not cover: rate limits, sessions, JWT, body limits, cache headers and the rest of this chapter.

CORS

First, the mechanics. The browser’s same-origin policy stops JavaScript served from https://app.example.com from reading responses of https://api.example.com, a different origin (scheme, host and port), unless the API opts in. CORS (Cross-Origin Resource Sharing) is that opt-in: the server names acceptable origins in Access-Control-* response headers. For requests that could have side effects, the browser first sends a preflight (an automatic OPTIONS request asking “may I send a POST with these headers?”) and proceeds only when the answer allows it. CORS protects browser users; it does not restrict non-browser clients such as curl, which ignore it entirely.

The cors middleware answers preflight requests and stamps the Access-Control-* response headers. It is configured through a fluent chain that ends in .Handler(), and it belongs to UseRouter so that preflights are answered even for unmatched paths.

Go
package main

import (
    "time"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/cors"
)

func main() {
    app := iris.New()

    crs := cors.New().
        // One comma-separated line; use AllowOrigins for a list.
        AllowOrigin("https://example.com,https://app.example.com").
        AllowHeaders("Content-Type", "Authorization").
        ExposeHeaders("X-Total-Count").
        MaxAge(12 * time.Hour).
        Handler()

    app.UseRouter(crs)

    app.Get("/api/data", func(ctx iris.Context) {
        ctx.JSON(iris.Map{"message": "cross-origin ready"})
    })

    app.Listen(":8080")
}

For dynamic origin decisions, pick one of three validators:

Go
// Match on the origin string alone.
cors.New().AllowOriginMatcherFunc(func(origin string) bool {
    return strings.HasSuffix(origin, ".example.com")
}).Handler()

// Context-aware: AllowOriginFunc(func(ctx iris.Context,
//     origin string) bool { ... })
// Regular expressions: AllowOriginRegex(`^https://.*\.example\.com$`)

Other chain methods: AllowOrigins("https://a.com", "https://*.b.com") for a list with wildcards, DisallowCredentials(), ReferrerPolicy(cors.NoReferrerWhenDowngrade), ExtractOriginFunc(cors.StrictOriginExtractor), SkipLocalRequests(true) and HandleErrorFunc to customize the rejection response. There is no AllowMethods method: a preflight’s Access-Control-Allow-Methods always lists every standard method, QUERY included, and route registration controls which verbs actually exist.

Rate Limiting

Picture each client owning a bucket of tokens: every request spends one token, and the bucket refills at a steady rate up to a maximum, the burst. A client that stays under the refill rate never notices the limiter; one that surges drains its bucket and is rejected until tokens accumulate again. This “token bucket” scheme tolerates short, normal bursts of activity while capping the sustained rate.

The rate middleware implements a token-bucket limiter per client. rate.Limit(limit, burst, rate.Options{}) takes events per second as a float; the rate.Every helper converts an interval, and the zero rate.Options keeps every default.

Go
package main

import (
    "time"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/rate"
    "github.com/kataras/iris/v14/rest/errors"
)

func main() {
    app := iris.New()

    // 1 request per second, bursts of up to 5.
    app.Use(rate.Limit(1, 5, rate.Options{}))

    // A stricter group: 10 per minute, with periodic cleanup of
    // clients idle for more than 5 minutes and a custom response.
    strict := rate.Limit(rate.Every(time.Minute), 10, rate.Options{
        PurgeEvery:       5 * time.Second,
        PurgeMaxLifetime: 5 * time.Minute,
        ExceedHandler: func(ctx iris.Context) {
            ctx.SetHeader("Retry-After", "60")
            errors.ResourceExhausted.Message(ctx, "rate limit exceeded")
        },
    })

    auth := app.Router("/auth", strict)
    auth.Post("/login", func(ctx iris.Context) {
        ctx.JSON(iris.Map{"ok": true})
    })

    app.Listen(":8080")
}

By default clients are identified by remote IP and rejected with a plain 429. To key on something else (an API key, a user ID), call rate.SetIdentifier from a middleware registered before the limiter:

Go
app.Use(func(ctx iris.Context) {
    rate.SetIdentifier(ctx, ctx.Header("X-API-Key"))
    ctx.Next()
})
app.Use(rate.Limit(1, 5, rate.Options{}))

Inside handlers, rate.Get(ctx) returns the *rate.Client (its ID, optional Data filled by the Options.ClientData hook, and the underlying limiter) for emitting X-RateLimit-* headers. The limiter state is in-process memory; limits shared across replicas need a proxy or a store-backed limiter in front.

rate.Limit returns a handler and manages the limiter’s lifetime for you. When you need the lifetime in your hands, for example to stop the purge goroutine of a limiter that lives shorter than the process, rate.New(limit, burst, rate.Options{}) returns the *rate.Limiter itself: register its Handler() and call Stop() when the limiter is no longer needed.

Recovery

The recovery middleware turns panics into 500 responses instead of crashed connections. Register it with UseRouter so it wraps everything that follows.

Go
import "github.com/kataras/iris/v14/middleware/recovery"

app.UseRouter(recovery.Handler)

app.Get("/boom", func(ctx iris.Context) {
    panic("something went wrong")
})

To control the response, register recovery.WithErrorHandler before recovery.Handler:

Go
app.UseRouter(
    recovery.WithErrorHandler(func(ctx iris.Context, err error) {
        // Logs the panic dump, answers the canonical INTERNAL body.
        errors.Internal.LogErr(ctx, err)
    }),
    recovery.Handler,
)

The error passed to the handler is a *context.ErrPanicRecovery with Cause, Stack, Callers, CurrentHandlerName, CurrentHandlerFileLine and the Request. The Request dump has its credential-bearing headers replaced with [redacted], the list being recovery.RedactedRequestHeaders, and Error() reports only the cause and the callers. Call LogMessage() when you want the dump as well, so that a handler formatting the error cannot put request headers in a response by accident. Builder-built applications already have recovery installed and wired to the central error map (errors.Internal.LogErr), so panics come back as JSON INTERNAL errors; see Chapter 9.

Compression

The compress middleware negotiates a content encoding from the client’s Accept-Encoding header (gzip, deflate, brotli, snappy, s2 or zstd) and wraps the response writer. It is a single handler:

Go
import "github.com/kataras/iris/v14/middleware/compress"

app.UseRouter(compress.Handler)

That is the whole integration. Responses are compressed only when the client advertises support; already-encoded responses pass through. With the builder, Compression(true) registers exactly this handler. For manual control the package also exposes compress.GetAcceptEncoding(r, offers...) and writer helpers, but for nearly every application the one-liner is correct.

Access Logging

The accesslog middleware writes one record per request with latency, status, sizes and any custom fields. Create it once, register its Handler with UseRouter, and close it on shutdown.

Go
package main

import (
    "os"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/accesslog"
)

func main() {
    app := iris.New()

    ac := accesslog.File("access_log.json")
    defer ac.Close()

    ac.AddOutput(os.Stdout)
    ac.SetFormatter(&accesslog.JSON{Indent: "  ", HumanTime: true})
    ac.IP = true
    ac.RequestBody = true
    ac.ResponseBody = false

    ac.AddFields(func(ctx iris.Context, f *accesslog.Fields) {
        f.Set("request_id", ctx.ID())
    })

    app.UseRouter(ac.Handler)

    app.Get("/", func(ctx iris.Context) {
        ctx.JSON(iris.Map{"hello": "world"})
    })

    app.Listen(":8080")
}

accesslog.New(w io.Writer) writes to any writer, accesslog.File(path) opens a buffered file, and accesslog.FileUnbuffered(path) trades throughput for immediate writes. Formatters include &accesslog.JSON{}, &accesslog.CSV{} and &accesslog.Template{Text: "..."}.

With the builder, LogRequests(true) installs a pre-tuned instance (JSON lines to access_log.json plus stdout, IP and body logging on) and registers its Close as a shutdown closer. The builder puts the access log outside compression, so that a panic is still logged, which means a compressed response reaches it as compressed bytes. It leaves the response body out of any line whose response carries a Content-Encoding other than identity, and does not call Redact for it; the status, the byte counts and every other field are logged as usual. A browser asks for compression on every request, so with Compression(true) expect response bodies in the log only for clients that did not. When the defaults do not fit, build your own and pass it to LogRequestsWith: same slot in the chain, your configuration:

Go
ac := accesslog.File("access_log.json")
ac.SetFormatter(&accesslog.JSON{HumanTime: true})
ac.ResponseBody = false

app := iris.NewBuilder().
    Prefix("/api").
    AllowOrigin("*").
    Compression(true).
    LogRequestsWith(ac). // instead of LogRequests(true)
    Health(true, "dev", "you@example.com").
    // Errors, Services, middlewares, API — see Chapter 3.
    Build()

Body Limits

The bodylimit middleware rejects a request whose body exceeds a byte limit with a 413. A request that declares a larger Content-Length is refused before your handler runs; one with no declared length fails as soon as reading it passes the limit. Under errors.Handler both answer 413 TOO_LARGE with the message “request body too large”.

Go
import "github.com/kataras/iris/v14/middleware/bodylimit"

// Global cap.
app.Use(bodylimit.New(2 * iris.MB))

// A larger cap only where uploads are expected.
uploads := app.Router("/uploads", bodylimit.New(100*iris.MB))

The iris.KB and iris.MB constants keep the sizes readable. This middleware complements (not replaces) the ContextOptions.BodyMaxMemory option, which caps what the body-reading helpers (ctx.Body, ctx.BindJSON and friends) will buffer; see Chapter 5.

Cache Headers

The cache middleware sets HTTP caching response headers (ETag, Cache-Control, Last-Modified, Expires, Pragma, Vary, Proxy-Authenticate) and answers conditional requests (If-None-Match, If-Modified-Since) with 304 Not Modified. It does not store responses; it teaches clients and proxies to.

An ETag is a short fingerprint of a response (a hash, in essence) sent alongside the body. On the next visit the client asks “has it changed?” by echoing that fingerprint in If-None-Match; if the resource is unchanged the server answers 304 Not Modified, a few bytes instead of the full body. Cache-Control works one step earlier: it tells the client how long it may reuse its stored copy without asking at all.

Go
package main

import (
    "time"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/cache"
)

func main() {
    app := iris.New()

    public := cache.New(cache.Options{
        ETag: true,
        CacheControl: cache.NewCacheControlBuilder().
            Public().
            MaxAge(time.Hour).      // browsers: 1 hour
            SMaxAge(2 * time.Hour). // shared caches: 2 hours
            MustRevalidate().
            Build(),
        LastModified: true,
        Expires:      1 * time.Hour,
        Vary:         cache.NewVaryBuilder().AcceptEncoding().Build(),
    })

    app.Get("/catalog", public, func(ctx iris.Context) {
        ctx.JSON(iris.Map{"items": []string{"a", "b", "c"}})
    })

    app.Listen(":8080")
}

cache.DefaultOptions is a sensible public-caching preset (ETag, one-hour max-age, two-hour s-maxage, 24-hour Expires, Vary: Accept-Encoding), usable as cache.New(cache.DefaultOptions). The CacheControlBuilder also offers Private(), NoCache(), NoStore() and ProxyRevalidate(); the VaryBuilder offers Accept(), AcceptLanguage(), UserAgent() and Add(...). Server-side response caching and store-backed strategies are the subject of Chapter 14.

Sessions

A cookie is a small piece of data the server asks the browser to store and send back with every later request, the standard way to recognize a returning visitor over stateless HTTP. A session builds on that: the cookie carries either the session data itself (signed and encrypted, as in the cookie backend below) or just an ID pointing at data stored server-side. Signing makes tampering detectable; encryption keeps the contents unreadable to the client.

The sessions middleware provides typed, generic session data: you declare a struct, pick a backend, and get compile-time-safe reads and writes. The defaults are secure: cookies are signed and encrypted, Secure is on and SameSite is Lax.

Go
package main

import (
    "os"
    "time"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/sessions"
)

type UserSession struct {
    ID       string
    Username string
}

func main() {
    app := iris.New()

    // 64-byte hash key, 32-byte block key: the payload is signed
    // and encrypted. Load them from configuration, never hardcode.
    opts := sessions.NewOptions(
        []byte(os.Getenv("SESSION_HASH_KEY")),
        []byte(os.Getenv("SESSION_BLOCK_KEY")),
    )
    opts.MaxAge = 1 * time.Hour // default is 24 hours
    // Defaults: Secure=true (HTTPS only), SameSite=Lax, Path="/".
    // For plain-HTTP local development only: opts.Secure = false

    db := sessions.NewCookieDatabase[UserSession](opts)

    // Loads the session per request, auto-commits at the end.
    app.Use(sessions.Handler(db, "my_session"))

    app.Post("/login", func(ctx iris.Context) {
        sess := sessions.Get[UserSession](ctx)
        sess.Set(UserSession{ID: "1", Username: "makis"})
        ctx.JSON(iris.Map{"ok": true})
    })

    app.Get("/me", func(ctx iris.Context) {
        sess := sessions.Get[UserSession](ctx)
        ctx.JSON(sess.Data)
    })

    app.Listen(":8080")
}

Useful session methods: SetFlash and the generic GetFlash for one-time messages; notice, ok := sess.GetFlash[string]("notice") returns the typed value and removes it, while a message of a different type stays in place with (zero, false), so a mistyped read is never destructive (use any as the type argument for the old untyped behavior). SetExtra and GetExtra[V]() carry data beside the typed Data struct; the promoted Extra field remains readable directly where the raw any is wanted. Then Delete(ctx) to expire the session, RegenerateID(ctx) to rotate the server-side ID after sign-in, and IsNew. sessions.ReadOnlyHandler loads without auto-commit, and sessions.Single binds a database to a fixed cookie name for use without the middleware.

Beyond the cookie backend, sessions.NewPostgresDatabase[T](table, conn, opts) and sessions.NewRedisDatabase[T](client, opts) store the data server-side. How sessions combine with authentication flows is covered in Chapter 10.

JWT

A JSON Web Token is a self-contained credential: a small JSON payload (the claims: who the user is, when the token expires) signed by the server. The signature lets the server trust an incoming token without a database lookup, because any tampering breaks verification. The flip side is that a stolen token works until it expires, which is why lifetimes are kept short and a blocklist enables revocation.

The jwt middleware signs and verifies JSON Web Tokens. In v14 the verifier reads the token from the Authorization: Bearer <token> header only; query-string extraction is opt-in, because URLs leak into logs.

Go
package main

import (
    "os"
    "time"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/jwt"
    "github.com/kataras/iris/v14/rest/errors"
)

type UserClaims struct {
    jwt.Claims
    Username string `json:"username"`
}

func main() {
    app := iris.New()

    secret := []byte(os.Getenv("JWT_SECRET"))
    signer := jwt.NewSigner(jwt.HS256, secret, 15*time.Minute)
    verifier := jwt.NewVerifier(jwt.HS256, secret)
    verifier.WithDefaultBlocklist() // enables token revocation

    app.Post("/token", func(ctx iris.Context) {
        token, err := signer.Sign(&UserClaims{
            Claims:   jwt.Claims{Subject: "user-123"},
            Username: "makis",
        })
        if errors.HandleError(ctx, err) {
            return
        }
        ctx.OK(iris.Map{"token": string(token)}, nil)
    })

    protected := app.Router("/api",
        verifier.Verify(func() any { return new(UserClaims) }))

    protected.Get("/me", func(ctx iris.Context) {
        claims, _ := jwt.Get[*UserClaims](ctx)
        ctx.JSON(iris.Map{"username": claims.Username})
    })

    app.Listen(":8080")
}

verifier.WithQueryToken() additionally accepts ?token=... for cases like browser file downloads that cannot set a header. Supported algorithms include HS256, HS384, HS512, RS256 and ES256. For complete login flows, refresh tokens and the higher-level auth package, see Chapter 10.

Basic Authentication

HTTP Basic Authentication is the protocol’s built-in login scheme: the client sends username:password with every request, base64-encoded but not encrypted, so it is only acceptable over HTTPS.

The basicauth middleware guards routes with HTTP Basic Authentication. Three constructors cover the usual cases:

Go
import "github.com/kataras/iris/v14/middleware/basicauth"

// Quick: a static user map.
guard := basicauth.Default(map[string]string{"admin": "secret"})

// Configured: realm, user source, re-ask interval. Options[U] names the
// user type the handlers read back with basicauth.GetUser[U](ctx).
guard = basicauth.New(basicauth.Options[basicauth.Map]{
    Realm:  "Admin Area",
    Allow:  basicauth.AllowUsersFile[basicauth.Map]("users.yml"),
    MaxAge: 5 * time.Minute,
})

// Shortcut: users straight from a JSON or YAML file.
guard = basicauth.Load("users.yml")

admin := app.Router("/admin", guard)

Default stores a *iris.SimpleUser, Load a basicauth.Map, and basicauth.AllowUsers(list) with a []YourUser stores your own type; ctx.User() sees each of them as a context.User. Suitable for internal dashboards and tooling; for user-facing authentication prefer sessions or JWT.

API Keys

basicauth and jwt both assume a person. The apikey middleware covers the caller that is not one: another service, a cron job, a webhook sender or a CLI tool, presenting one long random string it was handed once.

Go
import "github.com/kataras/iris/v14/middleware/apikey"

guard := apikey.New(apikey.Options{
    Keys: map[string]string{
        "reporting": os.Getenv("REPORTING_API_KEY"),
    },
})

api := app.Router("/api", guard)

The key arrives as Authorization: Bearer <key> by default, with the scheme matched case-insensitively per RFC 7235; set Scheme: apikey.NoScheme for a bare value on a header such as X-API-Key. Keys maps the principal name to its secret, so one key per consumer makes a leak traceable, and apikey.Get(ctx) names the caller that matched. When nothing else has set a user, a context.SimpleUser carrying that principal is stored too, so ctx.User() works.

The default is to deny: no Keys and no Lookup rejects every request and logs a warning on the first one. Hash: apikey.SHA256 lets Keys hold digests instead of working credentials, and Lookup resolves keys that live in a database, where an error is a 500 rather than a 401. Options.Query reads the key from a query parameter, which the access log then records with every other query argument, so mask it in a custom formatter first. Chapter 10 covers all of it.

Request IDs

The requestid middleware assigns each request a unique ID, exposes it through requestid.Get(ctx) and ctx.ID(), and reflects it in the X-Request-Id response header, the anchor for tracing a request across logs.

Go
import "github.com/kataras/iris/v14/middleware/requestid"

app.Use(requestid.New())

app.Get("/", func(ctx iris.Context) {
    ctx.JSON(iris.Map{"request_id": requestid.Get(ctx)})
})

The default generator honors a client-supplied X-Request-Id header and generates a UUIDv4 only when it is absent. That is convenient behind a trusted proxy that assigns IDs, but it lets arbitrary clients choose the value that lands in your logs. When IDs must be unforgeable, use the server-only generator:

Go
app.Use(requestid.New(requestid.ServerOnlyGenerator))

requestid.HashGenerator(includeBody) derives a fixed-length ID from the request hash instead (not unique across identical requests).

Internationalization

The i18n middleware loads translation files and detects the request language from a URL parameter, cookie, subdomain or the Accept-Language header.

Go
import "github.com/kataras/iris/v14/middleware/i18n"

locales := i18n.New()
if err := locales.Load("./locales/*/*", "en-US", "el-GR"); err != nil {
    panic(err)
}
locales.SetDefault("en-US")

app.Use(i18n.NewHandler(locales, i18n.HandlerOptions{
    URLParameter: "lang", // ?lang=el-GR
    Cookie:       "lang",
}))

app.Get("/", func(ctx iris.Context) {
    ctx.JSON(iris.Map{
        "message": i18n.GetMessage(ctx, "welcome", "Iris"),
        "locale":  i18n.GetLocale(ctx).Language(),
    })
})

i18n.SetLanguage(ctx, code) switches the language for the current visitor, and i18n.NewRouter serves path-prefixed locales (/en-US/...). Translation file formats, pluralization and template integration are covered in Chapter 15.

Views

The view middleware connects any template engine that implements a single-method interface:

Go
type Engine interface {
    ExecuteTemplate(w io.Writer, templateName string,
        layoutName string, data any) error
}

Register the engine once, render from handlers:

Go
import (
    "github.com/kataras/iris/v14/middleware/view"

    "github.com/kataras/blocks"
)

templates := blocks.New("./views")
if err := templates.Load(); err != nil {
    panic(err)
}

app.UseRouter(view.Handler(templates))

app.Get("/", func(ctx iris.Context) {
    view.Render(ctx, "index", "", iris.Map{"Title": "Home"})
})

Helpers on the package set per-request state: view.Layout, view.Data, view.KeyValue and view.AddFunc. Response rendering in full (including content negotiation) is in Chapter 6.

WebSockets

A WebSocket is a persistent, two-way connection between browser and server that begins as an ordinary HTTP request and then upgrades. Unlike the request/response cycle, either side can push messages at any moment, which is what chat, live dashboards and multiplayer features need.

The websocket middleware integrates the neffos WebSocket library.

Go
import (
    "github.com/kataras/iris/v14/middleware/websocket"

    "github.com/kataras/neffos"
    "github.com/kataras/neffos/gorilla"
)

events := neffos.Namespaces{
    "default": neffos.Events{
        "chat": func(c *neffos.NSConn, msg neffos.Message) error {
            c.BroadcastOthers(msg)
            return nil
        },
    },
}

server := neffos.New(gorilla.DefaultUpgrader, events)
app.Any("/ws", websocket.New(server, websocket.Options{}))

websocket.GetContext(conn) recovers the originating iris.Context inside event callbacks, useful for reading authentication state set by earlier middleware. neffos itself (namespaces, rooms, client libraries) is a third-party library with its own documentation.

Monitoring and Profiling

Four packages cover observability basics; the full story (metrics, tracing, dashboards) is Chapter 17.

monitor renders live process and OS statistics (CPU, RAM, connections, load):

Go
import "github.com/kataras/iris/v14/middleware/monitor"

m := monitor.New(monitor.Options{ViewTitle: "My App"})
app.Get("/monitor", m.View)        // HTML dashboard
app.Get("/monitor/stats", m.Stats) // JSON

httpcost measures what each request costs in elapsed time, memory and CPU, using configurable per-unit prices:

Go
import "github.com/kataras/iris/v14/middleware/httpcost"

app.Use(httpcost.New(httpcost.DefaultOptions))

app.Get("/report", func(ctx iris.Context) {
    // In a later middleware or accesslog field:
    if metric, ok := httpcost.Get(ctx); ok {
        _ = metric // metric.Cost and the raw measurements
    }
    ctx.JSON(iris.Map{"ok": true})
})

pprof mounts the standard Go profiling endpoints as a routing controller:

Go
import "github.com/kataras/iris/v14/middleware/pprof"

app.Router("/debug/pprof", pprof.New())
// then: go tool pprof http://localhost:8080/debug/pprof/profile

Guard it (behind ipaccess, basicauth or a debug-only conditional) before it reaches production.

servertiming emits the Server-Timing response header so browser devtools show your server-side phases:

Go
import "github.com/kataras/iris/v14/middleware/servertiming"

app.Use(servertiming.Handler)

app.Get("/api/data", func(ctx iris.Context) {
    servertiming.Start(ctx, "db", "Database Query")
    // ... query ...
    servertiming.End(ctx, "db")
    ctx.JSON(iris.Map{"data": "result"})
})

Utility Middleware

Smaller tools, each a one- or two-line integration.

methodoverride lets HTML forms issue PUT and DELETE through POST, via headers (X-HTTP-Method-Override and friends) or a _method form field. The target may be any standard method, QUERY included:

Go
import "github.com/kataras/iris/v14/middleware/methodoverride"

app.UseRouter(methodoverride.New(methodoverride.Options{
    // Defaults: override POST only, standard headers, "_method" field.
    SaveOriginalMethodContextKey: "_originalMethod",
}))
// ctx.Method() then reflects the overridden verb.

rewrite applies redirect rules in "CODE PATTERN TARGET" form:

Go
import "github.com/kataras/iris/v14/middleware/rewrite"

engine, err := rewrite.New(rewrite.Options{
    RedirectMatch: []string{
        "301 /old-api/(.*) /api/v1/$1",
    },
    PrimarySubdomain: "www", // redirect root domain to www.
})
if err != nil {
    panic(err)
}
app.UseRouter(engine.Handler)
// Or from a YAML/JSON file: app.UseRouter(rewrite.Load("redirects.yml"))

ipaccess restricts by IP or CIDR, with optional exceptions:

Go
import "github.com/kataras/iris/v14/middleware/ipaccess"

guard, err := ipaccess.New(
    []string{"127.0.0.1", "10.0.0.0/8"}, // allowed
    []string{"10.0.5.9"},                // blocked exceptions
)
if err != nil {
    panic(err)
}
admin := app.Router("/admin", guard)

versioning routes requests to versioned route trees based on a header, query parameter or subdomain:

Go
import "github.com/kataras/iris/v14/middleware/versioning"

app.UseRouter(versioning.New(versioning.Options{
    Default:    "1.0.0",
    Latest:     "2.0.0",
    Header:     "Accept-Version",
    QueryParam: "v",
    Versions: versioning.Versions{
        {Range: ">=1.0.0 <2.0.0", Path: "/v1", Deprecated: true},
        {Range: ">=2.0.0", Path: "/v2"},
    },
}))

Deprecated matches receive X-Api-Warn and deprecation headers.

modrevision serves build metadata (module version, revision, build time, environment); the builder’s Health step uses it for GET /health:

Go
import "github.com/kataras/iris/v14/middleware/modrevision"

app.Get("/health", modrevision.New(modrevision.Options{
    ServerName:     "My API",
    Env:            "production",
    Developer:      "team@example.com",
    CloudDetection: true,
}))

counter counts requests with a generic, notification-capable counter:

Go
import "github.com/kataras/iris/v14/middleware/counter"

c := counter.NewCounter[uint64]()
app.Use(counter.RequestCounter(c))

app.Get("/stats", func(ctx iris.Context) {
    ctx.JSON(iris.Map{"total_requests": c.Get()})
})

referrer parses the Referer header into its parts, so a landing route can record where a visitor arrived from without a third-party script:

Go
import "github.com/kataras/iris/v14/middleware/referrer"

app.Use(referrer.Handler)

app.Get("/landing", func(ctx iris.Context) {
    r := referrer.Get(ctx)
    if r.Type == referrer.ReferrerSearch {
        // Label is the engine ("Google", "Bing"); Query is the search text
        // when the engine still sends it, which most no longer do.
        ctx.JSON(iris.Map{"engine": r.Label, "query": r.Query})
        return
    }

    ctx.JSON(iris.Map{"type": r.Type.String(), "domain": r.Domain})
})

Type is one of indirect, direct, email, search or social. The header is client-supplied and often absent, so treat it as a hint for analytics and never as proof of where a request came from.

Auditing Requests and Responses

recorder keeps a copy of the request body and of the response body, so a later handler can read either after the handler that produced it has finished. Register recorder.Request, recorder.Response, or both.

Ordinarily the request body is drained by the first reader, and the response goes straight to the socket. That is what makes an audit trail awkward to write by hand, and what this middleware solves:

Go
import "github.com/kataras/iris/v14/middleware/recorder"

// Runs after the handler: call Next first, then read what was recorded.
func auditTrail(ctx iris.Context) {
    ctx.Next()

    entry := iris.Map{
        "request_id": ctx.ID(),
        "path":       ctx.Path(),
        "request":    string(recorder.ReadRequestBody(ctx)),
    }

    if rec := recorder.GetResponseRecorder(ctx); rec != nil {
        entry["status"] = rec.Code
        entry["response"] = rec.Body.String()
    }

    auditLog.Write(entry)
}

orders := app.Router("/admin/orders",
    recorder.Request, recorder.Response, auditTrail)

orders.Post("/", createOrder)

recorder.GetResponse(ctx) returns the same thing as an *http.Response when that shape is more convenient.

Two costs are worth stating plainly. Recording buffers the whole body in memory, so registering it globally turns every large upload and every file download into an allocation of the same size; put it on the routes that need the trail and nowhere else. And the recorded body is the real one, passwords and card numbers included, so scrub it before it reaches a log the way accesslog’s Redact does.

Geolocation

geolocation resolves the client IP to a country, region, city and timezone. The common production use is picking a sensible default before the visitor has chosen anything: a currency, a language, a nearest region.

Go
import (
    stderrors "errors" // "errors" here is the framework's rest/errors.

    "github.com/kataras/iris/v14/middleware/geolocation"
    "github.com/kataras/iris/v14/rest/errors"
)

app.Use(geolocation.New(geolocation.Options{
    APIKey:          os.Getenv("IP_API_KEY"),
    Cache:           geolocation.NewMemoryCache(10_000),
    DisableLanguage: true, // skip the second lookup when you do not need it.
}))

app.Get("/pricing", func(ctx iris.Context) {
    info, err := geolocation.Fetch(ctx)
    if err != nil {
        if stderrors.Is(err, geolocation.Err) {
            ctx.OK(iris.Map{"currency": "USD"}, nil) // fall back, do not fail.
            return
        }

        errors.HandleError(ctx, err)
        return
    }

    ctx.OK(iris.Map{"country": info.CountryCode, "city": info.City}, nil)
})

geolocation.Get(ctx) returns the already-resolved *Info without fetching, for handlers that run after the middleware.

The lookup is a network call to an external service, so two rules follow. Configure the cache, or a burst of traffic from one office becomes a burst of identical API calls. And write the fallback branch first, because the service will be slow or down at some point and a pricing page that fails closed on a geo lookup is worse than one that shows dollars.

Geolocation is a guess about a network address, not a fact about a person. It answers “which currency should this page default to”, never “is this request allowed”. A VPN defeats it in one click.

Captcha Verification

hcaptcha and recaptcha verify a challenge token with the provider before the handler runs. Register either as a route middleware on the endpoint that accepts the form:

Go
import "github.com/kataras/iris/v14/middleware/hcaptcha"

app.Post("/contact",
    hcaptcha.New(hcaptcha.Options{Secret: os.Getenv("HCAPTCHA_SECRET")}),
    func(ctx iris.Context) {
        // Only reached when the challenge passed.
        res, _ := hcaptcha.Get(ctx)
        ctx.Application().Logger().Infof("solved for %s", res.Hostname)

        submitTicket(ctx)
    })

recaptcha works the same way. Both take an Options value whose FailureHandler field replaces the default rejection response, a 429 with no body; recaptcha.DefaultFailureHandler and hcaptcha.DefaultFailureHandler are the ones it replaces:

Go
import (
    "github.com/kataras/iris/v14/middleware/recaptcha"
    "github.com/kataras/iris/v14/rest/errors"
)

app.Post("/signup", recaptcha.New(recaptcha.Options{
    Secret: os.Getenv("RECAPTCHA_SECRET"),
    FailureHandler: func(ctx iris.Context) {
        errors.PermissionDenied.Message(ctx, "captcha verification failed")
    },
}), signup)

Both packages can render the client-side form for you during development: hcaptcha.RenderForm(ctx, siteKey, "/contact") and recaptcha.GetFormHTML(siteKey, "/signup"). Real applications usually render their own markup and keep only the verification here.

The secret is a server-side credential and belongs in the environment, not in the source or in the template beside the site key. Verification adds a round trip to the provider on every submission, bounded by hcaptcha.VerifyTimeout (10 seconds by default), so a captcha in front of a hot path costs latency on every request that reaches it.

Serving gRPC on the Same Port

grpc lets one Iris application answer both gRPC and ordinary HTTP on a single port, which matters when you have exactly one TLS port to spend:

Go
import (
    grpcWrapper "github.com/kataras/iris/v14/middleware/grpc"
    "google.golang.org/grpc"
)

grpcServer := grpc.NewServer()
pb.RegisterGreeterServer(grpcServer, &greeterService{})

app := iris.New()
app.UseRouter(grpcWrapper.New(grpcServer))

// Ordinary routes are unaffected; gRPC traffic never reaches them.
app.Get("/health", healthHandler)

app.Run(iris.ServerOptions{
    Addr:     ":443",
    CertFile: "cert.pem",
    KeyFile:  "key.pem",
})

The wrapper inspects each request and forwards it to the gRPC server only when it is a gRPC call, which it decides from HTTP/2 plus the application/grpc content type. Everything else calls ctx.Next() and continues to the router.

That check is also the constraint: gRPC requires HTTP/2, and Go’s HTTP/2 support is only enabled over TLS. Serve this over plaintext and the gRPC half silently never matches, because the requests arrive as HTTP/1.1. Use Run with a certificate, as above, rather than Listen.

Adapting net/http Middleware

Anything written for net/http converts with iris.FromStd:

Go
// Supported forms:
//   iris.FromStd(h http.Handler)
//   iris.FromStd(func(w http.ResponseWriter, r *http.Request))
//   iris.FromStd(func(w http.ResponseWriter, r *http.Request,
//       next http.HandlerFunc))
app.Use(iris.FromStd(stdMiddleware))

The converted handler sees the route the way a standard one expects. In all three forms r.PathValue("id") returns the route parameter, and r.Pattern holds the matched route’s template as you registered it, for example /users/{id:uuid}, so standard middleware that labels metrics or traces by route works unchanged. Register that middleware with Use, not UseRouter: the pre-routing slot runs before a route is matched, so there r.Pattern is empty, as it is in the built-in 404 and 405 handlers.

And any handler chain can run conditionally with iris.NewConditionalHandler:

Go
app.Use(iris.NewConditionalHandler(func(ctx iris.Context) bool {
    return ctx.Header("X-Debug") == "1"
}, debugHeadersMiddleware))

Community Middleware

Thirteen more middleware live outside the framework, in iris-contrib/middleware. They cover the integrations that would drag a vendor SDK into the framework’s dependency tree if they shipped here:

MiddlewareWhat it adds
casbinAuthorization with ACL, RBAC and ABAC models
cloudwatchAWS CloudWatch metrics
csrfCross-site request forgery tokens
expmetricRequest counters exposed through expvar
newrelicThe official New Relic Go agent
pgPostgreSQL integration
prometheusA Prometheus metrics endpoint
secureA set of common security headers
throttler, tollboothicTwo other rate-limiting approaches
zapRequest logging through go.uber.org/zap

Each is its own Go module, so you install only the one you want and the rest of its dependency tree stays out of your build:

Shell
$ go get github.com/iris-contrib/middleware/casbin@master

Sentry maintains its own Iris handler in getsentry/sentry-go rather than here.

Two entries in that repository, cors and jwt, predate the framework’s own. Use the built-in middleware/cors and middleware/jwt instead: they are maintained alongside the framework and the builder wires both for you.

The main branch of that repository targets v14. The v12 line is kept unchanged at its v12 tag, so an application that has not migrated yet still has something to pin to.

These modules are maintained by their contributors rather than with the framework, so they move on their own schedule. Check when one was last touched before you build something load-bearing on it.

Summary

  • Thirty-one middleware packages live under github.com/kataras/iris/v14/middleware/; each is a plain handler or a small constructor that returns one.
  • Registration level matters: UseRouter for infrastructure that must see every request (CORS, compression, access logs, rewrites, recovery), Use for request-scoped behavior (rate and body limits, sessions, authentication, cache headers).
  • iris.NewBuilder already installs recovery, and (through its chain steps) CORS, compression, access logging and the /health endpoint, all wired to the rest/errors JSON error format.
  • The heavy hitters each take a handful of lines: cors.New()... .Handler(), rate.Limit(limit, burst, rate.Options{}), compress.Handler, accesslog.File(path), bodylimit.New(n), cache.New(cache.Options{...}) with its CacheControlBuilder, typed sessions with secure cookie defaults, a jwt verifier that reads the Authorization header by default, and apikey.New(apikey.Options{Keys: ...}) for the caller that is a program rather than a person.
  • requestid.ServerOnlyGenerator prevents clients from forging the IDs that reach your logs; the default generator trusts an inbound X-Request-Id.
  • The deeper treatments live in their own chapters: authentication in Chapter 10, caching in Chapter 14, i18n in Chapter 15, observability in Chapter 17.

Further Reading

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.