Chapter 18

Security Best Practices

What v14 secures by default, then transport security, request bounds, input validation, CORS, CSRF, security headers, secrets and a production checklist.

Many of Iris v14’s defaults are secure ones: session cookies carry Secure and SameSite=Lax, JWT verification reads only the Authorization header, and sign-in redirects are confined to same-site paths. A few are permissive instead, for compatibility, and each has a one-line opt-in. This chapter tours both, then covers the hardening you enable yourself: transport security, request bounding, input validation, private errors, session and cookie policy, authentication hardening, CSRF defense, static file safety, and secrets hygiene.

Background

Web security begins with one assumption: every part of an incoming request is chosen by the sender, and the sender may be hostile. The method, the path, every header, every cookie value, the body, an uploaded file’s name and content: all of it is attacker-controlled until your code proves otherwise. “Validate at the edge” and “bound the request”, two phrases this chapter repeats, are that assumption turned into practice.

Most attacks on a web application fall into a handful of families:

  • Injection: attacker input is spliced into a command, most famously a SQL query, so that what you meant to treat as data is executed as code instead.
  • Cross-site scripting (XSS): an attacker’s JavaScript ends up inside a page you serve, running in other users’ browsers with access to their session.
  • Cross-site request forgery (CSRF): a hostile page tricks a signed-in victim’s browser into sending a state-changing request to your application, riding on cookies the browser attaches automatically.
  • Credential attacks: passwords leaked from other sites are replayed against your sign-in form, or your own password database is stolen and brute-forced offline.
  • Denial of service (DoS): a client exhausts your memory, CPU or connections (a gigabyte body, a flood of requests) until real users cannot get through.

Two terms recur throughout the chapter. A cookie is a small piece of data the server asks the browser to store; the browser then attaches it to every later request to the same site automatically, which is what makes sessions work, and also what makes CSRF possible. TLS (the protocol behind https://) encrypts the connection between browser and server and proves the server’s identity, so nothing on the network path can read or tamper with the traffic.

Because no single control is perfect, security relies on defense in depth: independent layers arranged so that one failing does not collapse the rest. A typed {id:uuid} route parameter, a query that keeps data separate from SQL text, and a least-privilege database account all guard against the same injection, and any one of them alone would still hold.

Finally, defaults matter more than options. A protection that must be switched on is a protection that is sometimes forgotten; a protection that is on from the start fails safe. That is why this chapter opens with what Iris already does for you, and why every escape hatch is a deliberate, visible line of code rather than silent configuration drift.

Secure by Default

Several protections are active in every Iris application without any configuration:

AreaDefault behavior
Session cookiessessions.NewOptions sets Secure: true and SameSite: Lax
Response cookiesctx.AddCookie sets HttpOnly and SameSite=Lax
JWT extractionjwt.NewVerifier reads the Authorization header only
CORS credentialsAccess-Control-Allow-Credentials: true is never sent with a wildcard origin. It is sent with a reflected specific origin, which is what cors.New() produces: see CORS
Sign-in redirectsauth.Auth.SigninHandler follows only same-site relative paths
OAuth2 codesauthorization and device codes are 128-bit URL-safe opaque tokens
Builder appspanics are recovered and failures render generic error bodies

Each default has a deliberate, one-line escape hatch for the cases that genuinely need different behavior:

  • Plain HTTP in local development. A Secure cookie is never sent over http://localhost, so sessions appear to “not stick”. Set opts.Secure = false on your sessions.Options for local runs only. In production behind a TLS-terminating proxy there is nothing to change: the browser connection is HTTPS even when the application sees HTTP.
  • Tokens in the URL. The ?token= query extractor is off because URLs leak into access logs, proxy logs, browser history and Referer headers. Call verifier.WithQueryToken() only when a header cannot be set, for example browser-initiated file downloads.
  • Credentials with a wildcard origin. Browsers reject that combination, so the CORS middleware does not emit it. Credentialed CORS works normally for specific origins. cors.New().AllowCredentials() opts in for non-browser clients that need it anyway. Note the limit of this guarantee: it covers the literal * only, and cors.New() defaults to reflecting whatever origin asked. Use cors.Strict() and name your origins, as the CORS section describes.
  • External sign-in redirects. redirect_to / redirect_uri values that are absolute URLs, protocol-relative (//host) or backslash-obfuscated are ignored and the token is returned as JSON instead, which closes an open-redirect vector. Perform external redirects in your own handler after validating the destination against your own allowlist.

The rest of the chapter covers the protections you enable yourself.

Transport Security

TLS needs two things: a private key and a certificate, a signed statement from a certificate authority (CA) that the key really belongs to your domain, which is what the browser verifies before showing the padlock. You can buy certificates, generate them internally, or obtain them for free from Let’s Encrypt, an automated public CA. Everything in this section is about getting those two pieces into the server.

Serve TLS directly with iris.ServerOptions:

Go
package main

import (
    "github.com/kataras/iris/v14"
)

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

    app.Get("/", func(ctx iris.Context) {
        ctx.WriteString("served over TLS")
    })

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

For automatic certificates from Let’s Encrypt, set Letsencrypt: true and list the domains the certificate covers. The first Hosts entry is the root domain:

Go
app.Run(iris.ServerOptions{
    Addr:        ":443",
    Letsencrypt: true,
    Hosts:       []string{"example.com", "www.example.com"},
})

When Letsencrypt is enabled and CertFile/KeyFile are also provided, the files act as a fallback if the Let’s Encrypt challenge fails, which is useful for local testing. For full control over protocol versions and cipher suites, supply your own TLSConfig *tls.Config (with certificates loaded into it) and leave CertFile, KeyFile and Letsencrypt unset.

Two practical notes:

  • app.Run starts a single listener. There is no automatic port-80 listener redirecting HTTP to HTTPS; if you need that redirect, run a separate plain HTTP server or let your reverse proxy handle it.
  • Terminating TLS at a proxy (nginx, a cloud load balancer) is equally valid. Secure cookies keep working because the browser’s connection is HTTPS.

Bounding the Request

Untrusted request bodies are unbounded unless you cap them. Set BodyMaxMemory to limit every body-reading call (Body, ReadJSON, ReadJSONStream and ReadMultipartRelated) application-wide. It also bounds decompression-bomb reads when the compress middleware is active:

Go
package main

import "github.com/kataras/iris/v14"

func main() {
    app := iris.New(iris.Options{
        Context: iris.ContextOptions{
            BodyMaxMemory: 10 * iris.MB,
        },
    })
    // ...
}

A partial iris.Options literal merges with the defaults, so this configures only the body limit. Reading past the limit fails with ErrRequestEntityTooLarge, which the rest/errors envelope renders as 413 TOO_LARGE with the message “request body too large”. The default of 0 means unlimited. For per-route limits, use the bodylimit middleware:

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

upload := app.Router("/upload")
upload.Use(bodylimit.New(2 * iris.MB))

If you accept compressed request bodies, bound the decompressed size too. compress.Handler expands a body with no ceiling of its own, and a compression ratio of a thousand to one is ordinary for repetitive data, so a one-megabyte upload can become a gigabyte in memory before any handler sees it. compress.Safe is the same middleware with a 32 MB cap (compress.DefaultMaxDecompressedSize), and compress.Limited takes your own:

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

app.Use(compress.Safe)         // 32 MB decompressed
app.Use(compress.Limited(8 * iris.MB))

Reading past the cap fails with compress.ErrDecompressedBodyTooLarge. A BodyMaxMemory or a bodylimit registered after the compression middleware bounds the same stream, because by then the body being read is already the decompressed one. compress.Handler logs a warning at first use when nothing bounds it.

Rate limiting

A rate limiter puts a ceiling on how fast any one client can call you, so a single misbehaving or malicious client cannot monopolize the server. The standard model is the token bucket: each client has a bucket holding up to burst tokens, every request spends one, and tokens drip back at a fixed rate: when the bucket is empty, requests are rejected until it refills.

The rate middleware implements a token bucket per client (keyed by remote IP by default). rate.Limit(limit, burst, rate.Options{}) takes requests per second, a burst size and a settings value whose zero form keeps every default; rate.Every converts an interval into the rate:

Go
import (
    "time"

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

// 10 requests per second with a burst of 10.
app.Use(rate.Limit(rate.Every(time.Second), 10, rate.Options{}))

In builder-built applications the exceed handler is pre-wired to the rest/errors JSON renderer: clients over the limit receive a structured 429 response under the RESOURCE_EXHAUSTED code with a “try again in” hint. Override it per limiter with the Options.ExceedHandler field.

Give expensive or abusable endpoints their own, stricter budget:

Go
app.Post("/signin",
    rate.Limit(rate.Every(10*time.Second), 3, rate.Options{}),
    signinHandler,
)

Two useful settings: Options.PurgeEvery and Options.PurgeMaxLifetime clean up idle client entries (each falls back to its own default when the other is left zero), and rate.SetIdentifier, called from a middleware registered before the limiter, switches the client key from the IP to, for example, an API key.

Request IDs

Correlation IDs end up in logs and support tickets, so do not let clients choose them. requestid.ServerOnlyGenerator ignores any client-supplied X-Request-Id header and always generates a fresh ID on the server:

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

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

IP allowlists

For endpoints that should only be reachable from known networks, the ipaccess middleware matches exact IPs, CIDR ranges and regular expressions. Blocked patterns are checked first; anything that matches no allowed pattern receives 403. An empty allowed list means “no allowlist”, so ipaccess.New(nil, blocked) expresses a blocklist-only policy:

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

admin := app.Router("/admin")
admin.Use(ipaccess.Must(
    []string{"10.0.0.0/8", "203.0.113.7"},
    nil,
))

A pattern that is neither an IP nor a CIDR is compiled as a regular expression, anchored at the start. The anchoring matters: write 10.0.0.* expecting a glob and you get a regexp where . means any character, and with no leading anchor it would also match 110.0.0.99. The end stays open so a prefix pattern such as ^198\.51\.100\. still covers a whole /24; add $ when you want an exact match. Prefer a CIDR when you mean a range.

The check runs against the connection address. Behind a reverse proxy that address is the proxy’s, so either enforce IP rules at the proxy or make sure your deployment forwards and trusts client addresses correctly. If you do configure RemoteAddrHeaders, the proxy must overwrite those headers on every request: the middleware reads what arrives, and a client that sets X-Forwarded-For itself would otherwise choose its own address.

Input Validation

Validate at the edge, in the request type itself. ctx.BindJSON[T]() decodes the body and, when the type implements iris.Validatable (Validate() error), runs validation automatically; the handler never sees an invalid value and never calls Validate manually:

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

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age"`
}

func (r CreateUserRequest) Validate() error {
    return validation.Join(
        validation.String("name", r.Name).NotEmpty().Length(3, 50),
        validation.String("email", r.Email).Email(),
        validation.Number("age", r.Age).InRange(13, 120),
    )
}

func createUser(ctx iris.Context) {
    input, ok := ctx.BindJSON[CreateUserRequest]()
    if !ok {
        return // a detailed 4xx response is already written.
    }
    // input is well-formed and validated.
}

On failure the client receives a structured, field-by-field validation error; see Chapter 9 for the response shape.

Typed path parameters

Route macros are a validation layer too. A parameter typed as uuid, int or any other macro rejects malformed values before your handler runs:

Go
app.Get("/users/{id:uuid}", getUser)
app.Get("/posts/{page:int min(1)}", listPosts)

In builder-built applications a malformed typed parameter renders a 400 response under the INVALID_ARGUMENT code with an “invalid path parameter” message. A handler that declares {id:uuid} can therefore pass id to the database layer without re-checking its format.

SQL injection

Never build SQL by string concatenation. Use parameterized queries, so the driver keeps data separate from the statement:

Go
row := db.QueryRow(
    "SELECT id, name FROM users WHERE id = $1", id,
)

Typed macros upstream make this safer still: an {id:uuid} parameter cannot carry ' OR '1'='1 into your query in the first place. See Chapter 13 for database patterns.

Keeping Internals Private

Error responses must never leak connection strings, hostnames, SQL, file paths or stack traces. The rest/errors pipeline (Chapter 9) is built around that rule: unmapped internal failures render as a generic 500 while the full cause is logged server-side.

At the response site, log-and-hide explicitly:

Go
import "github.com/kataras/iris/v14/rest/errors"

func getReport(ctx iris.Context) {
    report, err := service.BuildReport(ctx)
    if err != nil {
        // Logs the full error server-side;
        // the client sees a generic message.
        errors.Internal.LogErr(ctx, err)
        return
    }
    ctx.JSON(report)
}

errors.Internal.ErrPrivate(ctx, err) does the same without choosing the log level for you. When the sensitive detail is created far from the response (in a repository or service layer), wrap it at the source with errors.Private:

Go
return errors.Private(
    fmt.Errorf("query failed on host %s: %w", host, err),
)

A Private-wrapped error keeps its message for server-side logs but is never shown to the client, no matter where it later surfaces in the errors pipeline. Combined with one central errors.NewOptions() error map (which maps sql.ErrNoRows to 404 out of the box), the failure modes of your application are enumerable and none of them exposes internals.

Sessions and Cookies

Session cookies are Secure and SameSite=Lax from the start; your job is to pick strong keys, a sensible lifetime, and to rotate the session ID at sign-in.

Go
import (
    "net/http"
    "time"

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

type UserSession struct {
    UserID string
    Role   string
}

// hashKey: 64 bytes, blockKey: 32 bytes; load both from the
// environment, never from source code or a committed file.
func newSessionHandler(hashKey, blockKey []byte) iris.Handler {
    opts := sessions.NewOptions(hashKey, blockKey)
    opts.MaxAge = 2 * time.Hour // default is 24 hours.
    // Secure=true and SameSite=Lax are already set. Escalate to
    // Strict when the application is sensitive enough to accept
    // losing cross-site top-level navigation with the cookie:
    opts.SameSite = http.SameSiteStrictMode

    db := sessions.NewCookieDatabase[UserSession](opts)
    return sessions.Handler(db, "session")
}

For local development over http://localhost, set opts.Secure = false, and only there.

Rotate the session ID at sign-in

Session fixation works by making a victim authenticate under an identifier the attacker already knows. Defeat it by rotating the identifier whenever the privilege level changes, most importantly right after sign-in:

Go
func signin(ctx iris.Context) {
    // ...verify credentials first...

    session := sessions.Get[UserSession](ctx)
    session.Set(UserSession{UserID: user.ID, Role: user.Role})

    if err := session.RegenerateID(ctx); errors.HandleError(ctx, err) {
        return
    }

    ctx.OK(iris.Map{"message": "signed in"}, nil)
}

RegenerateID deletes the old server-side entry and issues a new ID. It is a no-op for cookie-based databases, whose data lives inside the signed cookie and has no server-side identifier to rotate. On sign-out, call session.Delete(ctx) to expire the session entirely.

Cookies you set yourself

ctx.AddCookie writes cookies with HttpOnly and SameSite=Lax set. Add the Secure attribute and adjust SameSite per call:

Go
ctx.AddCookie("prefs", "compact",
    iris.CookieSecureForce,
    iris.CookieSameSite(http.SameSiteStrictMode),
)

iris.CookieSecure sets the attribute only when the current connection is TLS; iris.CookieSecureForce sets it unconditionally, which is what you want behind a TLS-terminating proxy. ctx.AddCookieOptions applies a set of options to every cookie set later in the same request.

Setting a cookie twice in one response does not send it twice. ctx.AddCookie, ctx.SetCookie and ctx.RemoveCookie replace a Set-Cookie line that already carries the same name, path and domain, the way SetHeader replaces a header, so a middleware that refreshes a cookie a handler already set leaves one line for the browser to read. A cookie with the same name on another path or domain is still its own line.

Authentication Hardening

JWT

Keep access tokens short-lived and verify them from the Authorization header, which is the verifier’s only extraction source by default:

Go
import (
    "os"
    "time"

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

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

func protectAPI(app *iris.Application, key []byte) {
    verifier := jwt.NewVerifier(jwt.HS256, key)
    verifier.WithDefaultBlocklist() // enable token revocation.

    api := app.Router("/api")
    api.Use(verifier.Verify(func() any { return new(UserClaims) }))
}
  • Sign with a short max age (jwt.NewSigner(jwt.HS256, key, 15*time.Minute)) and issue a refresh token alongside via the signer’s NewTokenPair. A stolen access token then expires in minutes. Chapter 10 shows the full flow.
  • The blocklist makes sign-out meaningful: revoked tokens fail verification even before they expire.
  • The signing key comes from the environment (os.Getenv), never from a source file. See Secrets Hygiene.

Basic authentication

Basic auth transmits credentials base64-encoded (encoded, not encrypted), so it is only acceptable over TLS. Enforce that in the middleware itself and store bcrypt hashes rather than plaintext passwords:

Go
import (
    "time"

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

auth := basicauth.New(basicauth.Options[basicauth.Map]{
    Realm:     "Restricted",
    Allow:     basicauth.AllowUsersFile[basicauth.Map]("users.yml", basicauth.BCRYPT),
    HTTPSOnly: true, // reject any non-HTTPS request outright.
    MaxAge:    2 * time.Hour,
    MaxTries:  3, // 403 after three failures.
})

admin := app.Router("/admin")
admin.Use(auth)

Password hashing cost

Passwords are never stored, only their hashes: one-way fingerprints that let you verify a password without keeping it. General-purpose hashes such as SHA-256 are unsuitable because they are fast: an attacker holding a stolen database can test billions of guesses per second. bcrypt is designed to be slow, and its cost factor lets you make it slower still as hardware improves.

The auth/identity password hasher uses bcrypt. Its work factor is the package-level password.Cost, defaulting to bcrypt.DefaultCost. Raising it makes offline brute-force proportionally more expensive at the price of more CPU per hash; hashes created with a different cost remain verifiable because the cost is embedded in the hash itself:

Go
import "github.com/kataras/iris/v14/auth/identity/password"

func init() {
    password.Cost = 12 // default is bcrypt.DefaultCost (10).
}

Finally, recall from Secure by Default that the auth SDK confines sign-in redirects to same-site paths and that auth/oauth2 issues high-entropy opaque codes; neither needs configuration, so do not re-implement them less safely.

CORS

An origin is the scheme-host-port triple of a URL: https://app.example.com and https://api.example.com are different origins. Browsers enforce the same-origin policy: a script running on one origin cannot read responses from another. CORS (Cross-Origin Resource Sharing) is the server’s controlled opt-out, response headers that tell the browser which foreign origins may read your API’s responses. For requests with side effects, the browser first sends a preflight, an OPTIONS request asking permission before the real one goes out.

Cross-origin policy is an allowlist. Name your exact origins; a wildcard is only for genuinely public, credential-free APIs:

Go
import (
    "time"

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

app.UseRouter(cors.New().
    ExtractOriginFunc(cors.StrictOriginExtractor).
    AllowOrigins(
        "https://app.example.com",
        "https://admin.example.com",
    ).
    AllowHeaders("Content-Type", "Authorization").
    MaxAge(10 * time.Minute).
    Handler())

StrictOriginExtractor refuses to treat requests without a well-formed Origin header as same-origin-safe.

Name your origins. cors.New() on its own allows every origin, echoes the requesting one back, and advertises Access-Control-Allow-Credentials: true alongside it. That combination is what lets a page on any domain run

JavaScript
fetch("https://your-api/me", {credentials: "include"})

and read the response as the logged-in user. The wildcard * is handled (browsers reject * with credentials, and the middleware suppresses the header there unless you call AllowCredentials()), but a reflected specific origin is not, and a reflected origin is what the default produces.

cors.Strict() is cors.New() with credentials off until you ask for them:

Go
app.UseRouter(cors.Strict().
    AllowOrigin("https://app.example.com").
    AllowCredentials().
    Handler())

Wildcards in an origin pattern match one label, so https://www.example.* accepts https://www.example.com and https://www.example.org but not https://www.example.evil.com. A pattern written without the dot, https://www.example*, is a plain prefix match and will accept https://www.exampleshop.com; use it only when that is what you mean.

If you use the builder, the AllowOrigin step accepts the same comma-separated origin line and wires the handler at the right position in the chain; pass your production origins there instead of "*".

CSRF Protection

Iris does not bundle a CSRF-token middleware, and for most v14 applications you do not need one. Defense is layered:

SameSite cookies are the primary defense. Session cookies default to SameSite=Lax, which stops the browser from attaching them to cross-site subrequests and cross-site form POSTs, the classic CSRF vector. Two consequences follow. First, never mutate state on GET: Lax still sends the cookie on top-level GET navigations, so a state-changing GET would remain forgeable. Second, escalate to SameSiteStrictMode when the application warrants it (see Sessions and Cookies).

Header-carried tokens are immune. A JWT sent in the Authorization header is attached by your JavaScript, not by the browser, so a cross-site attacker cannot cause it to be sent. Token-authenticated APIs get CSRF resistance for free.

Watch what method override widens. methodoverride.New accepts _method from the query string and the form body by default, so a cross-site form POST can reach a route you registered as DELETE. Only POST is overridable, so a GET can never become a DELETE, but if you rely on “a browser cannot make a cross-site DELETE” as part of your reasoning, restrict the override to headers, which a cross-site form cannot set:

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

app.UseRouter(methodoverride.New(methodoverride.Options{HeadersOnly: true}))

Require a custom header on cookie-authenticated APIs. Cross-site HTML forms cannot set custom headers, and a cross-origin script that tries triggers a CORS preflight that your strict CORS policy rejects. A small middleware turns that into a hard guarantee:

Go
func requireCustomHeader(ctx iris.Context) {
    switch ctx.Method() {
    case "GET", "HEAD", "OPTIONS":
        ctx.Next()
        return
    }

    if ctx.Header("X-Requested-With") == "" {
        errors.PermissionDenied.Message(ctx, "custom request header required")
        return
    }

    ctx.Next()
}

api := app.Router("/api")
api.Use(requireCustomHeader)

Your clients then send X-Requested-With: XMLHttpRequest (or any custom header you choose) on every state-changing request.

Server-rendered forms. If you serve classic HTML forms and want synchronizer tokens on top of SameSite, implement them with the sessions API from this chapter: generate a random token with crypto/rand, store it in the typed session data, embed it in a hidden form field, and compare the posted value against the session on submission. It is a dozen lines of your own code, with no framework package required.

Static File Safety

The file server serves what is in the directory, including dotfiles such as .git, .env and .htpasswd, which have no business being downloadable. Block them with fileserver.BlockHidden as the AssetValidator:

Go
import "github.com/kataras/iris/v14/controller/fileserver"

app.Router("/static", fileserver.New("./assets", fileserver.Options{
    IndexName:      "index.html",
    AssetValidator: fileserver.BlockHidden,
}))

BlockHidden denies any request whose path contains a dot-prefixed segment. Note that this includes conventional directories like .well-known; if you need one of those served, compose your own validator on top of it. AssetValidator is a general per-request hook (return false to deny), so it also serves for authorization checks on downloads. Better still, keep secrets and VCS metadata out of the served directory entirely: serve a dedicated assets directory, not the project root.

As a backstop, the file server logs a warning at startup when the directory you point it at holds one of the well-known sensitive dotfiles and no AssetValidator is set. It is a reminder, not a defence: the files are still served.

Symbolic links are the other way out of the directory. A directory given as a string is served through http.Dir, which checks the request path as text but follows a link inside the directory that points somewhere else, so a link to /etc or to your home directory is served like any other folder. To serve only what is under the directory, open it as an os.Root and pass its file system:

Go
root, err := os.OpenRoot("./assets")
if err != nil {
    log.Fatal(err)
}

app.Router("/static", fileserver.New(root.FS(), fileserver.Options{
    IndexName:      "index.html",
    AssetValidator: fileserver.BlockHidden,
}))

A link that leaves the root now answers 404. That costs two things. The root holds the directory open for the life of the process, so a deploy that replaces the directory keeps serving the old one until the process restarts. And a link that leaves the directory is no longer served at all, which breaks a layout that relies on one on purpose, such as ./assets/shared linked to a directory outside it.

Security Headers

Security headers are instructions to the browser about how your responses may be used. The two most consequential: Content-Security-Policy (CSP) declares which sources of scripts, styles and images a page may load, so that even successfully injected markup cannot execute an attacker’s script, a strong second line of defense against XSS. Strict-Transport-Security (HSTS) tells the browser to reach your domain only over HTTPS from then on, closing the window in which a first plaintext request could be intercepted.

Iris does not inject opinionated security headers; set the ones that match your application in a small router-level middleware so they apply to every response, including 404s:

Go
func securityHeaders(ctx iris.Context) {
    ctx.SetHeader("X-Content-Type-Options", "nosniff")
    ctx.SetHeader("X-Frame-Options", "DENY")
    ctx.SetHeader("Referrer-Policy", "strict-origin-when-cross-origin")
    ctx.SetHeader("Content-Security-Policy", "default-src 'self'")

    if ctx.IsSSL() {
        ctx.SetHeader("Strict-Transport-Security",
            "max-age=31536000; includeSubDomains")
    }

    ctx.Next()
}

app.UseRouter(securityHeaders)

Tailor the Content-Security-Policy to what the application actually loads: an API that serves only JSON can keep the strict default-src 'self', while a server-rendered site needs entries for its scripts, styles and images. Send Strict-Transport-Security only over TLS; a plaintext response carrying HSTS is ignored by browsers anyway.

Secrets Hygiene

Signing keys, session keys, database passwords and admin credentials live in the environment (or the secret manager that feeds it), never in source code and never in committed configuration files. Committed YAML holds addresses, timeouts and feature flags; the environment holds everything you would not paste into a code review.

Read secrets at startup and fail fast when one is missing:

Go
import (
    "fmt"
    "os"
)

func loadSecrets(cfg *Config) error {
    cfg.AdminPassword = os.Getenv("ADMIN_PASSWORD")
    if cfg.AdminPassword == "" {
        return fmt.Errorf(
            "ADMIN_PASSWORD environment variable is required")
    }
    return nil
}

A missing secret at boot is a deployment error with an obvious message; a missing secret discovered at request time is an outage. Two adjacent habits: treat access logs as sensitive too (they can contain tokens and personal data; rotate them and keep them out of version control), and prefer environment-first configuration end to end, as described in Chapter 11.

Production Checklist

Every line below is one or two lines of code away from an application built with the patterns in this book:

ConcernAction
Request bodiesBodyMaxMemory in iris.Options, or bodylimit.New per route
Compressed bodiescompress.Safe (or compress.Limited) instead of compress.Handler
Response cachingcache.Strict(opts) on routes that serve both anonymous and signed-in callers
Rate limitingrate.Limit(limit, burst, rate.Options{}); builder apps render the JSON 429 automatically
CORScors.Strict() with exact origins; cors.New() reflects any origin with credentials
Method overridemethodoverride.Options{HeadersOnly: true} if you rely on verb reachability
Cookiesdefaults are Secure + SameSite=Lax; add iris.CookieSecureForce behind a TLS proxy
Session fixationsession.RegenerateID(ctx) right after sign-in
Dotfilesfileserver.BlockHidden as the AssetValidator
Request IDsrequestid.New(requestid.ServerOnlyGenerator)
Password hashingraise password.Cost above the bcrypt default
TLSapp.Run(iris.ServerOptions{...}) with certificates or Letsencrypt: true
Access logLogRequests(true); set AccessLog.Redact or RequestBody = false; rotate and gitignore the log file
Health endpoint/health reports env and developer; mount your own if that is too much exposure
Secretsenvironment only; fail fast at startup when missing
Error responsescentral errors.NewOptions() map; errors.Private for sensitive causes
Recoveryinstalled by the builder; do not add a second one
Dependencieskeep them current; run govulncheck ./... in CI

Summary

  • Iris v14 ships secure defaults: Secure + SameSite=Lax session cookies, header-only JWT extraction, same-site-only sign-in redirects, SameSite=Lax on ctx.AddCookie, and high-entropy OAuth2 codes, each with a deliberate opt-out.
  • A few defaults favour compatibility instead, and each has an opt-in: cors.New() reflects any origin with credentials (cors.Strict()), request bodies and decompression are unbounded (BodyMaxMemory, compress.Safe), cache.New applies a public policy to credentialed requests too (cache.Strict), and the access log records request bodies (AccessLog.Redact).
  • Bound every request: BodyMaxMemory (or bodylimit) caps bodies at 413, rate.Limit throttles clients with a pre-wired JSON 429 in builder apps, requestid.ServerOnlyGenerator stops ID forgery, and ipaccess restricts network reach.
  • Validate at the edge: ctx.BindJSON[T]() runs Validate() error automatically, and typed route macros reject malformed parameters with a 400 before your handler runs.
  • Keep internals private: the errors pipeline renders generic 500s while logging full causes; errors.Private guarantees a sensitive error never reaches a client.
  • Harden authentication: RegenerateID at sign-in, short-lived JWTs with a blocklist, basic auth over TLS only, a raised password.Cost.
  • CSRF defense is SameSite cookies first, header-carried tokens second, and a custom-header requirement for cookie-authenticated APIs, with no dedicated middleware needed.
  • Secrets live in the environment, are read at startup, and missing ones fail the boot, not the request.

Further Reading

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