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 |
| 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
Securecookie is never sent overhttp://localhost, so sessions appear to “not stick”. Setopts.Secure = falseon yoursessions.Optionsfor 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 andRefererheaders. Callverifier.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, andcors.New()defaults to reflecting whatever origin asked. Usecors.Strict()and name your origins, as the CORS section describes. - External sign-in redirects.
redirect_to/redirect_urivalues 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:
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:
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.Runstarts 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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.
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:
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:
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:
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’sNewTokenPair. 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:
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:
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:
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
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:
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:
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:
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:
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:
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:
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:
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:
| 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=Laxsession cookies, header-only JWT extraction, same-site-only sign-in redirects,SameSite=Laxonctx.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.Newapplies apublicpolicy to credentialed requests too (cache.Strict), and the access log records request bodies (AccessLog.Redact). - Bound every request:
BodyMaxMemory(orbodylimit) caps bodies at 413,rate.Limitthrottles clients with a pre-wired JSON 429 in builder apps,requestid.ServerOnlyGeneratorstops ID forgery, andipaccessrestricts network reach. - Validate at the edge:
ctx.BindJSON[T]()runsValidate() errorautomatically, 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.Privateguarantees a sensitive error never reaches a client. - Harden authentication:
RegenerateIDat sign-in, short-lived JWTs with a blocklist, basic auth over TLS only, a raisedpassword.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: the canonical survey of the most common web application attack families and how they are exploited.
- OWASP CSRF Prevention Cheat Sheet: the defense layers behind this chapter’s CSRF guidance, including SameSite cookies and custom request headers.
- OWASP Password Storage Cheat Sheet: current guidance on password hashing algorithms and work factors.
- MDN: Content Security Policy: how CSP directives are written and what each one restricts.
- MDN: Using HTTP cookies:
the
Secure,HttpOnlyandSameSiteattributes explained in depth. - Go vulnerability management: how
govulncheckfinds known-vulnerable dependencies in your builds.