<!--
  Security Best Practices
  From the Iris book, chapter 18, covering Iris 14.0.0 on Go 1.27.
  Import path: github.com/kataras/iris/v14
  Canonical page: https://iris-go.com/docs/security-best-practices/
-->
# Chapter 18: Security Best Practices

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:

| Area              | Default behavior                                                                                                                                                                       |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Session cookies   | `sessions.NewOptions` sets `Secure: true` and `SameSite: Lax`                                                                                                                          |
| Response cookies  | `ctx.AddCookie` sets `HttpOnly` and `SameSite=Lax`                                                                                                                                     |
| JWT extraction    | `jwt.NewVerifier` reads the `Authorization` header only                                                                                                                                |
| CORS credentials  | `Access-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](#cors) |
| Sign-in redirects | `auth.Auth.SigninHandler` follows only same-site relative paths                                                                                                                        |
| OAuth2 codes      | authorization and device codes are 128-bit URL-safe opaque tokens                                                                                                                      |
| Builder apps      | panics 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](#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](https://iris-go.com/docs/error-handling/) 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](https://iris-go.com/docs/database-integration/) 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](https://iris-go.com/docs/error-handling/)) 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](https://iris-go.com/docs/authentication-authorization/) 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](#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](#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

```js
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](#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](https://iris-go.com/docs/configuration-management/).

## Production Checklist

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

| Concern           | Action                                                                                                  |
| ----------------- | ------------------------------------------------------------------------------------------------------- |
| Request bodies    | `BodyMaxMemory` in `iris.Options`, or `bodylimit.New` per route                                         |
| Compressed bodies | `compress.Safe` (or `compress.Limited`) instead of `compress.Handler`                                   |
| Response caching  | `cache.Strict(opts)` on routes that serve both anonymous and signed-in callers                          |
| Rate limiting     | `rate.Limit(limit, burst, rate.Options{})`; builder apps render the JSON 429 automatically              |
| CORS              | `cors.Strict()` with exact origins; `cors.New()` reflects any origin with credentials                   |
| Method override   | `methodoverride.Options{HeadersOnly: true}` if you rely on verb reachability                            |
| Cookies           | defaults are `Secure` + `SameSite=Lax`; add `iris.CookieSecureForce` behind a TLS proxy                 |
| Session fixation  | `session.RegenerateID(ctx)` right after sign-in                                                         |
| Dotfiles          | `fileserver.BlockHidden` as the `AssetValidator`                                                        |
| Request IDs       | `requestid.New(requestid.ServerOnlyGenerator)`                                                          |
| Password hashing  | raise `password.Cost` above the bcrypt default                                                          |
| TLS               | `app.Run(iris.ServerOptions{...})` with certificates or `Letsencrypt: true`                             |
| Access log        | `LogRequests(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                        |
| Secrets           | environment only; fail fast at startup when missing                                                     |
| Error responses   | central `errors.NewOptions()` map; `errors.Private` for sensitive causes                                |
| Recovery          | installed by the builder; do not add a second one                                                       |
| Dependencies      | keep 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

- [OWASP Top Ten](https://owasp.org/www-project-top-ten/): the canonical
  survey of the most common web application attack families and how they
  are exploited.
- [OWASP CSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html):
  the defense layers behind this chapter's CSRF guidance, including
  SameSite cookies and custom request headers.
- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html):
  current guidance on password hashing algorithms and work factors.
- [MDN: Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP):
  how CSP directives are written and what each one restricts.
- [MDN: Using HTTP cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies):
  the `Secure`, `HttpOnly` and `SameSite` attributes explained in depth.
- [Go vulnerability management](https://go.dev/doc/security/vuln/): how
  `govulncheck` finds known-vulnerable dependencies in your builds.
