Chapter 10

Authentication & Authorization

Basic Auth in one line, the generic JWT SDK, role and permission guards, session-based login, and the built-in identity server and OAuth2 provider.

Iris ships several layers of authentication support, from a single-line HTTP Basic Auth middleware to a complete OAuth2 identity server. This chapter helps you pick the right layer, then walks through each one: middleware/basicauth for internal surfaces, the generic auth SDK for token-based user-facing APIs, middleware/jwt for raw token signing and verification, middleware/sessions for browser applications, and a tour of the auth/identity and auth/oauth2 packages. Authentication answers who the caller is; authorization answers what the caller may do. The chapter closes the loop with plain guard handlers for roles and permissions.

Background

Two Questions, Two Answers

Authentication and authorization are separate problems that are easy to blur. Authentication establishes identity: is this request really from Maria? Authorization decides rights: Maria is real, but may she delete this record? HTTP encodes the difference in two status codes you will meet throughout the chapter: 401 means “I do not know who you are”, 403 means “I know who you are, and the answer is no”.

Credentials, Tokens, Sessions and Cookies

A credential is the proof of identity a caller presents: a username and password, an API key, a client certificate. The complication is that HTTP is stateless (the server forgets you the moment a response is sent), so every request must carry proof. Sending the password itself each time (which is exactly what Basic Authentication does) multiplies the chances of it leaking, so most systems exchange the password once for something weaker and shorter-lived.

That something takes two shapes. A token is a string the server issues after one successful credential check; the client attaches it to later requests instead of the password. Most tokens are bearer tokens: whoever holds one is treated as the user, no further questions, which is why tokens expire quickly and why revocation matters. A session is the server-side variant: the server keeps the signed-in state in its own storage, and the client holds only a random session identifier that points at it. Tokens push state to the client; sessions keep it on the server.

A cookie is the browser-side transport for either. It is a small piece of data a server asks the browser to store, which the browser then sends back automatically with every request to that site. Three attributes recur in this chapter: HttpOnly (page scripts cannot read the cookie, which blunts token theft via injected JavaScript), Secure (sent over HTTPS only) and SameSite (limits sending on cross-site requests, the raw material of request forgery).

How a JWT Works

A JSON Web Token (JWT, RFC 7519) is a token with structure: three base64url-encoded parts (header, payload, signature) joined by dots. The payload carries claims: statements such as the user’s ID, roles and an expiry time. The signature is computed over the other two parts with a key, so a verifier can prove the token was issued by the key holder and has not been altered, without a database lookup. That self-contained verification is the JWT’s whole appeal for APIs. The catch to internalize: a JWT is signed, not encrypted. Anyone who obtains one can decode and read every claim, so the payload must never contain secrets, which is why this chapter insists the user type embedded in tokens stays small and clean. And because a stateless token cannot be recalled once issued, access tokens are kept short-lived and paired with refresh tokens or server-side revocation checks.

Password Hashing

Storing passwords as-is turns any database leak into a catastrophe: every account is compromised at once, and (since people reuse passwords) so are their accounts elsewhere. The remedy is hashing: a one-way function transforms the password into a fixed string that cannot be reversed; you store only the hash and re-run the function at sign-in to compare. General-purpose hashes like SHA-256 are too fast: attackers test billions of guesses per second offline. Password hashing therefore uses deliberately slow, salted algorithms such as bcrypt, the one used in this chapter’s examples. The salt is a random per-user value mixed in so identical passwords produce different hashes and precomputed lookup tables are useless.

Choosing an Approach

You are buildingReach for
An internal or admin surface with a handful of operatorsmiddleware/basicauth
Another service, a cron job or a webhook sender, with no user to sign inmiddleware/apikey
A user-facing API with access and refresh tokensthe auth package (auth.Auth[T])
A resource server that only verifies tokens issued elsewheremiddleware/jwt
A browser application with server-side statemiddleware/sessions
A standalone identity provider for many applicationsauth/identity + auth/oauth2

The layers compose. A typical production application protects its admin routes with basic auth, its public API with the auth SDK, and adds small guard handlers for role checks, all registered per router, so protection lives where the routes are mounted.

Basic Authentication

HTTP Basic Authentication sends a username and password with every request. It requires no token infrastructure, which makes it the right tool for internal dashboards and admin APIs served over HTTPS, and the wrong tool for anything user-facing.

(On the wire it is an Authorization: Basic header holding username:password base64-encoded. Base64 is an encoding, not encryption: anyone who can observe the traffic can read the credentials, which is why HTTPS is non-negotiable here.)

The middleware is a plain iris.Handler, so it applies wherever you mount it, a specific child router, not the whole application:

Go
package main

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

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

    admin := app.Router("/admin", basicauth.Default(map[string]string{
        "admin": "admin_password",
        "ops":   "ops_password",
    }))

    admin.Get("/stats", func(ctx iris.Context) {
        username, _ := ctx.User().GetUsername()
        ctx.Writef("hello, %s", username)
    })

    app.Listen(":8080")
}

On success the middleware stores the authenticated user on the request; ctx.User() retrieves it. Unauthenticated requests receive a 401 challenge automatically.

In builder-based applications the same handler slots into the API step, exactly as Chapter 3 shows:

Go
API("/admin", basicauth.Default(map[string]string{
    cfg.AdminUser: cfg.AdminPassword,
}), api.NewAdminAPI).

Hashed Passwords from a File

Never commit plain-text passwords. basicauth.Load reads a JSON or YAML user list and, with the BCRYPT option, compares the user input against bcrypt hashes:

YAML
# users.yml — either a flat "username: password" map or a list of
# entries with at least username and password fields.
- username: admin
  password: "$2a$10$..." # bcrypt hash
  role: admin
- username: ops
  password: "$2a$10$..."
  role: operator
Go
auth := basicauth.Load("users.yml", basicauth.BCRYPT)

Load keeps each entry as a basicauth.Map, so the role above survives. To decode the same file into your own struct instead, pass basicauth.AllowUsersFile[Operator]("users.yml", basicauth.BCRYPT) as the Allow of basicauth.New, described next.

(The $2a$10$ prefix of a bcrypt hash records the algorithm variant and its cost factor, here 2^10 internal rounds. Raising the cost makes every guess proportionally slower for an attacker, at the price of a slower comparison at sign-in.)

Custom Validation and Rate Limiting

basicauth.New accepts an Options[U] struct, typed by your own user, when you need database-backed users, credential expiration or a failure limit:

Go
import (
    "time"

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

    "golang.org/x/crypto/bcrypt" // third-party
)

func newAdminAuth(repo *OperatorRepository) iris.Handler {
    return basicauth.New(basicauth.Options[*Operator]{
        Realm:    basicauth.DefaultRealm,
        MaxAge:   2 * time.Hour, // re-ask for credentials after 2h.
        MaxTries: 3,             // 403 after 3 failures.
        GC:       basicauth.GC{Every: 3 * time.Hour},
        Allow: func(ctx iris.Context, username,
            password string) (*Operator, bool) {
            op, err := repo.GetByUsername(ctx, username)
            if err != nil {
                return nil, false
            }

            err = bcrypt.CompareHashAndPassword(
                []byte(op.PasswordHash), []byte(password))
            if err != nil {
                return nil, false
            }

            return op, true
        },
    })
}

Whatever Allow returns is what the handlers get back, typed:

Go
admin.Get("/me", func(ctx iris.Context) {
    op, _ := basicauth.GetUser[*Operator](ctx) // no type assertion
    since, _ := basicauth.AuthorizedAt(ctx)     // first login, needs MaxAge
    ctx.JSON(iris.Map{"username": op.Username, "since": since})
})

ctx.User() still returns the same value as a context.User, and ctx.Logout() forgets the login. For a static list of your own type, basicauth.AllowUsers(operators) takes a []Operator and reads the credentials from the Username and Password fields (json tags count), or from wherever a basicauth.Credentials option points.

API Keys for Machine Callers

Basic auth and the SDK both assume a person: a password to remember, a session to end, a token to refresh. A cron job, a webhook sender, a reporting service or a CLI tool has none of those. It has one long random string it was handed once and presents on every request, and what the server owes it is a constant-time comparison and a name in the log.

middleware/apikey is that. The key arrives on a header, by default Authorization: Bearer <key>, and the middleware matches it against the configured keys:

Go
package main

import (
    "os"

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

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

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

    api := app.Router("/api", guard)
    api.Get("/orders", func(ctx iris.Context) {
        principal, _ := apikey.Get(ctx) // "reporting" or "billing".
        ctx.JSON(iris.Map{"called_by": principal})
    })

    app.Listen(":8080")
}

Keys maps the principal to its secret and not the other way round, so two callers cannot share an entry: one key per consumer is what makes a leak traceable and a revocation survivable.

The default is to deny. A middleware with no Keys and no Lookup rejects every request and logs a warning on the first one, rather than letting everyone through while the deployment is missing its configuration. AllowAnonymous opens that door for local development and is ignored the moment a key exists.

The scheme is matched case-insensitively, per RFC 7235. A caller that cannot set Authorization gets its own header instead, and then the value is bare:

Go
apikey.New(apikey.Options{
    Header: "X-API-Key",
    Scheme: apikey.NoScheme, // the "-" sentinel: accept a bare value.
    Keys:   keys,
})

NoScheme is a sentinel rather than the empty string on purpose: an unset Scheme field keeps the Bearer default, because a field nobody filled in must not turn a check off.

Two options keep the working credential out of the configuration. Hash is applied to the presented key before comparison, so Keys hold digests:

Go
apikey.New(apikey.Options{
    Hash: apikey.SHA256,
    Keys: map[string]string{
        "reporting": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
    },
})

A shared secret is a high-entropy random string and not a password, so a plain digest is the right hash here: there is nothing to brute-force. Lookup covers the keys that live in a table, and is consulted only after the static keys miss:

Go
apikey.New(apikey.Options{
    Lookup: func(ctx iris.Context, key string) (string, bool, error) {
        row, err := tokens.FindByHash(ctx, apikey.SHA256(key))
        if stderrors.Is(err, sql.ErrNoRows) {
            return "", false, nil // not a match: a 401.
        }
        if err != nil {
            return "", false, err // the store is down: a 500.
        }

        return row.Name, !row.Revoked, nil
    },
})

The distinction in that last branch matters: an error is a 500 and not a 401, because a database that is down must not read to the caller as an invalid key.

Options.Query reads the key from a query parameter when the header is absent, for a caller that cannot set headers at all. Turn it on only after reading what it costs: a key in a URL ends up in browser history, proxy logs and referrer headers, and, when middleware/accesslog is registered, in your own access log, which records every query argument. Its Redact hook covers request and response bodies only, so the parameter has to be dropped or masked in a custom formatter (ac.SetFormatter) before you enable it.

On success the principal is on the context. apikey.Get(ctx) returns it, and when nothing else has set a user the middleware also stores a context.SimpleUser whose ID and Username are that principal, so code written against ctx.User() sees the caller without knowing where the credential came from. An existing user is never replaced.

The rejection reasons are apikey.ErrMissing (no key, or a value the scheme does not fit) and apikey.ErrInvalid (a key that matched nothing), wrapped into the error an ErrorHandler receives, so the two can be logged differently. The default renders the rest/errors Unauthenticated envelope.

The Auth SDK

The auth package is the recommended way to secure a user-facing API. Its central type, auth.Auth[T], signs and verifies JSON Web Tokens for your own user type T. It ships ready-made handlers for the whole token lifecycle (SigninHandler, RefreshHandler, SignoutHandler, SignoutAllHandler) plus a VerifyHandler middleware, EdDSA (Ed25519) signatures, encrypted refresh tokens with rotation, and optional secure cookie storage for browser clients.

You supply two things: a configuration (the keys) and a Provider (the user store).

A note on the key material before the YAML below: EdDSA is an asymmetric signature algorithm (Ed25519 key pairs). Asymmetric means the two keys split the roles (the private key signs tokens, the public key can only verify them), so other services can check your tokens without ever holding a key that could forge one. The /.well-known/jwks.json endpoint you will meet shortly publishes exactly that public key, in the standard JSON Web Key Set format. Symmetric algorithms such as HS256 use one shared secret for both jobs, which is simpler but means every verifier can also mint tokens.

Configuration and Keys

The configuration declares which headers carry the token, an optional cookie, and the signing keys. It usually loads from a YAML file:

YAML
# auth.yml
Headers:
  - "Authorization"
  - "X-Authorization"
Cookie: # optional; enables encrypted cookie storage for browsers.
  Name: "iris_auth_cookie"
  Secure: true
  Hash: "..."  # 64 characters, HMAC authentication.
  Block: "..." # 32 characters, AES-256 encryption.
Keys:
  - ID: IRIS_AUTH_ACCESS # required: short-lived access tokens.
    Alg: EdDSA
    MaxAge: 2h
    Private: |+
      -----BEGIN PRIVATE KEY-----
      ...
      -----END PRIVATE KEY-----
    Public: |+
      -----BEGIN PUBLIC KEY-----
      ...
      -----END PUBLIC KEY-----
  - ID: IRIS_AUTH_REFRESH # optional: enables the refresh flow.
    Alg: EdDSA
    MaxAge: 720h # 30 days; then the user must sign in again.
    EncryptionKey: "..." # refresh tokens travel encrypted.
    Private: |+ # own key pair, same PEM shape as above.
      ...
    Public: |+
      ...

The IRIS_AUTH_ACCESS key is mandatory; adding IRIS_AUTH_REFRESH enables refresh tokens. If the file does not exist, the load error prints a freshly generated example configuration you can start from. Loading options:

Go
// One call: bind the file and construct the instance (panics on error).
sso := auth.MustLoad[Customer]("auth.yml")

// Or bind manually, e.g. when auth config is part of your app config
// (see Chapter 11):
var cfg auth.Configuration
if err := cfg.BindFile("auth.yml"); err != nil {
    log.Fatal(err)
}
sso := auth.Must[Customer](cfg)

// Development and tests only: random, non-persistent keys.
var devCfg auth.Configuration
_ = devCfg.BindRandom()

The User Type and a Database-Backed Provider

T is any struct: it becomes the JWT payload, so keep it small and free of secrets. A Provider[T] connects the SDK to your user store:

Go
type Provider[T User] interface {
    Signin(ctx context.Context, username, password string) (T, error)
    Refresh(ctx context.Context, t T) (T, error)
    ValidateToken(ctx context.Context, claims StandardClaims, t T) error
    InvalidateToken(ctx context.Context, claims StandardClaims, t T) error
    InvalidateTokens(ctx context.Context, t T) error
}

Signin runs on sign-in, Refresh before a new token pair is issued, ValidateToken on every verified request, and the two Invalidate methods implement server-side sign-out. A complete provider against a SQL database, with a single revocations table for both “sign out this token” and “sign out everywhere”:

Go
package main

import (
    "context"
    "database/sql"
    "fmt"
    "time"

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

    "golang.org/x/crypto/bcrypt" // third-party
)

// Customer is embedded in the token payload: small, no secrets.
type Customer struct {
    ID     string   `json:"id"`
    Email  string   `json:"email"`
    Roles  []string `json:"roles"`
    Active bool     `json:"active"`
}

// CustomerProvider implements auth.Provider[Customer].
//
// Schema used below:
//
//    customers(id, email, active, password_hash)
//    revocations(token_id, customer_id, revoked_at)
type CustomerProvider struct {
    DB *sql.DB
}

func (p *CustomerProvider) Signin(ctx context.Context,
    username, password string) (Customer, error) {
    var (
        c    Customer
        hash string
    )

    err := p.DB.QueryRowContext(ctx,
        `SELECT id, email, active, password_hash
         FROM customers WHERE email = $1`, username).
        Scan(&c.ID, &c.Email, &c.Active, &hash)
    if err != nil {
        return Customer{}, fmt.Errorf("invalid credentials")
    }

    err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    if err != nil {
        return Customer{}, fmt.Errorf("invalid credentials")
    }

    return c, nil
}

// Refresh reloads the customer before a new token pair is signed,
// so profile or role changes propagate on the next refresh.
func (p *CustomerProvider) Refresh(ctx context.Context,
    c Customer) (Customer, error) {
    err := p.DB.QueryRowContext(ctx,
        `SELECT id, email, active FROM customers WHERE id = $1`,
        c.ID).Scan(&c.ID, &c.Email, &c.Active)
    return c, err
}

// ValidateToken runs on every verified request. A token is rejected if
// its ID (or, for refresh tokens, the access-token ID it originates
// from) was revoked, or if all of the customer's tokens issued before a
// "sign out everywhere" event are revoked.
func (p *CustomerProvider) ValidateToken(ctx context.Context,
    claims auth.StandardClaims, c Customer) error {
    var revoked bool
    err := p.DB.QueryRowContext(ctx,
        `SELECT EXISTS (SELECT 1 FROM revocations
         WHERE token_id IN ($1, $2)
            OR (customer_id = $3 AND revoked_at >= $4))`,
        claims.ID, claims.OriginID, c.ID, claims.IssuedAt).
        Scan(&revoked)
    if err != nil {
        return err
    }
    if revoked {
        return fmt.Errorf("token revoked")
    }
    return nil
}

func (p *CustomerProvider) InvalidateToken(ctx context.Context,
    claims auth.StandardClaims, c Customer) error {
    _, err := p.DB.ExecContext(ctx,
        `INSERT INTO revocations (token_id, revoked_at)
         VALUES ($1, $2)`, claims.ID, time.Now().Unix())
    return err
}

func (p *CustomerProvider) InvalidateTokens(ctx context.Context,
    c Customer) error {
    _, err := p.DB.ExecContext(ctx,
        `INSERT INTO revocations (customer_id, revoked_at)
         VALUES ($1, $2)`, c.ID, time.Now().Unix())
    return err
}

For fully stateless deployments, ValidateToken and the Invalidate methods may simply return nil; tokens then remain valid until they expire. The context passed to every method is the request’s iris.Context when invoked through the built-in handlers, so a provider can inspect the request if it needs to.

Registering as a Service

Construct the instance once, attach the provider, and register it as a service so any controller can receive it:

Go
package main

import (
    "database/sql"
    "log"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/auth"
)

func main() {
    db, err := sql.Open("pgx", "postgres://localhost/shop")
    if err != nil {
        log.Fatal(err)
    }

    sso := auth.MustLoad[Customer]("auth.yml")
    sso.AddProvider(&CustomerProvider{DB: db})

    app := iris.NewBuilder(iris.Defaults()).
        Prefix("/api").
        AllowOrigin("https://shop.example.com").
        Compression(true).
        LogRequests(true).
        NoHealth().
        Deferrables(db).
        Services(db, sso).
        Middlewares().
        API("/account", NewAccountAPI).
        Build()

    // Optional, at the root: lets other services verify your tokens.
    app.Get("/.well-known/jwks.json", sso.JWKSHandler())

    app.Listen(":8080")
}

Verifying Requests per Router

The controller receives the *auth.Auth[Customer] through its constructor and applies VerifyHandler exactly where protection is needed: sign-in and refresh stay public, everything under /me requires a valid token:

Go
package main

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

type AccountAPI struct {
    sso *auth.Auth[Customer]
}

func NewAccountAPI(sso *auth.Auth[Customer]) *AccountAPI {
    return &AccountAPI{sso: sso}
}

func (api *AccountAPI) Routing(r iris.Router) {
    r.Post("/signin", api.sso.SigninHandler)
    r.Post("/refresh", api.sso.RefreshHandler)

    me := r.Router("/me", api.sso.VerifyHandler())
    me.Get("/", api.profile)
    me.Post("/signout", api.sso.SignoutHandler)
    me.Post("/signout-all", api.sso.SignoutAllHandler)
}

func (api *AccountAPI) profile(ctx iris.Context) {
    ctx.OK(auth.GetUser[Customer](ctx), nil)
}

The flow, end to end, is four acts. In each one the SDK’s handler is the only thing your routes see; it drives your Provider and the key registry behind it:

The auth SDK token flow Each Auth handler drives your Provider and the kid key registry: signin mints an access and refresh pair linked by OriginID, verify checks the access token and loads the user, refresh rotates both, and signout invalidates them. SIGNIN POST /signin Auth handler reads username and password from JSON, form or multipart Provider.Signin your store returns the user; an error becomes a 401 jwt.Keys.SignToken kid IRIS_AUTH_ACCESS, then IRIS_AUTH_REFRESH if configured 200 with access_token, refresh_token, expiry, plus the encrypted cookie when Cookie.Name is set VERIFY GET a protected route VerifyHandler reads the token in Headers order, then the cookie jwt.Keys.Get(kid) the header alg must match the key's alg, then Future, Leeway Provider.ValidateToken then each VerifyUserFunc, then ctx.SetUser(user) ctx.Next(), and auth.GetUser[T](ctx) is available to every handler below REFRESH POST /refresh RefreshHandler fails outright when kid IRIS_AUTH_REFRESH is missing Provider.Refresh no provider is tolerated; any other error fails it jwt.Keys.SignToken a new pair: the access and the refresh token both rotate 200 with a new pair SIGNOUT POST /signout or /signout-all SignoutHandler reads the access token from the header or the cookie Provider.InvalidateToken or InvalidateTokens(ctx, user) for every token of that user cookie removed ctx.SetUser(nil), and the context values are cleared no response body The refresh token's claims carry OriginID, the access token's jti, so revoking an access token can revoke its refresh token too. And verify() accepts any configured kid, so a refresh token presented as a Bearer access token passes VerifyHandler: reject it inside ValidateToken by requiring claims.OriginID == "".
Each Auth handler drives your Provider and the kid key registry: signin mints an access and refresh pair linked by OriginID, verify checks the access token and loads the user, refresh rotates both, and signout invalidates them.

One consequence of that design is worth guarding against. verify accepts any token signed by a configured key, and the refresh key is configured, so a client that presents its refresh token as Authorization: Bearer <refresh_token> passes VerifyHandler. The refresh token’s claims carry OriginID (the access token’s jti) and an access token’s do not, so the check belongs in your ValidateToken: require claims.OriginID == "" and reject anything else.

Claims Checks with VerifyUserFunc

VerifyHandler accepts optional functions that further validate the already-verified user. Use them for cheap, declarative checks that apply to a whole router:

Go
import stdContext "context"

active := r.Router("/orders", api.sso.VerifyHandler(
    func(ctx stdContext.Context, c Customer) error {
        if !c.Active {
            return fmt.Errorf("account disabled")
        }
        return nil
    }))

A failing check produces the same 401 response as an invalid token, through the instance’s ErrorHandler (replaceable via SetErrorHandler).

Reading the User in Handlers

After VerifyHandler, three package-level helpers are available:

Go
// The verified user value.
user := auth.GetUser[Customer](ctx)

// The verified token: raw bytes plus standard claims.
token, ok := auth.GetToken(ctx)

// True when a verified user is stored on this request.
if auth.UserAuthorized(ctx) { /* ... */ }

The Request Scope

In a multi-tenant API almost every query needs one value off the verified user: the tenant, the customer, the organisation the request belongs to. A handler that derives it again is a second chance to get it wrong, and a handler that forgets to filter by it is a data leak. auth.Scope derives it once, right after VerifyHandler, and every handler after that reads it back:

Go
app.Use(a.VerifyHandler(), auth.Scope(func(u Customer) string {
    return u.TenantID
}))

func listInvoices(ctx iris.Context) {
    tenantID, ok := auth.ScopeID[string](ctx)
    if !ok {
        return
    }

    invoices, err := svc.InvoicesOf(ctx, tenantID)
    ctx.List(invoices, err)
}

ScopeID is a function of the auth package rather than a context method, because the scope is stored by auth. It reports false when Scope did not run or when it stored an id of another type, so a zero id you must not trust cannot be mistaken for a real one.

Two rulings are worth knowing before you rely on it. A missing user is a 401 with auth.ErrMissingUser, the same failure VerifyHandler answers with, and so is a user of a different type: reading the user untyped and deriving a scope from a zero value would answer 200 with an empty scope, which is a cross-tenant read wearing the disguise of the next sentence. A derived zero id, on the other hand, is stored as it is, because a global administrator may legitimately have no scope; guard those requests by checking the id in the handler, not by expecting Scope to refuse them.

The cost is one Values read, the derive call and one Values set per request; ScopeID is one map lookup and one type assertion. No reflection is involved on either side. The stamping half, writing the scope onto every body a client sends, is mvc.BeforeWrite in Chapter 24.

The cookie the SDK issues is both encrypted and authenticated: the Block key encrypts the content (AES), and the Hash key signs it (HMAC), so the browser stores the token but can neither read nor undetectably alter it.

When Cookie.Name is set (with Hash and Block, both required), SigninHandler also stores the access token in an encrypted, HttpOnly, SameSite=Lax cookie, and VerifyHandler falls back to that cookie when no Authorization header is present. The same Auth[T] instance then serves API clients and browser pages alike. A sign-in request may carry a ?redirect_to=/path query; for safety, only same-site relative paths are followed; absolute or scheme-bearing targets are ignored and the token is returned as JSON instead.

The JWT Middleware

middleware/jwt is the thin layer beneath the auth SDK: a Signer and a Verifier without providers, refresh flows or user stores. Reach for it when you only need to mint or verify tokens: for example, a resource server that trusts tokens issued by another service.

Go
package main

import (
    "time"

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

type userClaims struct {
    Username string `json:"username"`
}

func main() {
    secret := []byte("your-256-bit-secret-keep-it-safe")

    signer := jwt.NewSigner(jwt.HS256, secret, 15*time.Minute)
    verifier := jwt.NewVerifier(jwt.HS256, secret)

    app := iris.New()

    app.Post("/token", func(ctx iris.Context) {
        // Authenticate the caller first, then:
        token, err := signer.Sign(userClaims{Username: "makis"})
        if errors.HandleError(ctx, err) {
            return // logged, and a canonical INTERNAL body sent.
        }
        ctx.Write(token)
    })

    api := app.Router("/api",
        verifier.Verify(func() any { return new(userClaims) }))
    api.Get("/profile", func(ctx iris.Context) {
        claims, _ := jwt.Get[*userClaims](ctx)
        ctx.JSON(claims)
    })

    app.Listen(":8080")
}

verifier.Verify returns a middleware that extracts, verifies and decodes the token, then exposes the claims through jwt.Get[T](ctx) and the full token through jwt.GetVerifiedToken(ctx). T is what the constructor passed to Verify returns, here *userClaims; Get reports false instead of panicking when the request carries no claims or claims of another type. Invalid or missing tokens receive a 401 via the verifier’s replaceable ErrorHandler. The default one stores the reason as a private error, so under errors.Handler the body reads "message":"Unauthorized" whatever your error options map: a client cannot read from it why its token was refused, and the reason stays in your logs and collector.

Notes for v14:

  • By default the verifier reads the Authorization: Bearer <token> header only. Tokens in the URL query string leak into access logs, proxy logs and browser history, so the ?token= extractor is opt-in: verifier.WithQueryToken().
  • Server-side revocation is available through verifier.WithDefaultBlocklist() (in-memory); a blocklisted request can then be invalidated with ctx.Logout().
  • Both sides support AES-GCM payload encryption: signer.WithEncryption(key, nil) and verifier.WithDecryption(key, nil).
  • Production systems should prefer asymmetric algorithms (jwt.EdDSA, RSA/ECDSA variants) so verifying services never hold the signing key.
  • The header check is exact: alg, typ and nothing else. A token from an identity provider such as Auth0 also names its key in a kid field, so it is refused with jwt.ErrTokenAlg until you set the verifier’s optional HeaderValidator field. The ValidateHeader method of a github.com/kataras/jwt key set fits there. It loads the issuer’s public keys from its JWKS URL and picks one by the kid of each token:
Go
import kjwt "github.com/kataras/jwt"

keySet, err := kjwt.NewRemoteKeySet(jwksURL)
if err != nil {
    panic(err)
}
defer keySet.Close()

verifier := jwt.NewVerifier(nil, nil)
verifier.HeaderValidator = keySet.ValidateHeader

Role and Permission Guards

Authorization in Iris does not require a framework feature: a guard is a plain handler that inspects the verified user and either calls ctx.Next() or writes an error and stops. Combined with the rest/errors codes, guards stay small and composable:

Go
package main

import (
    "slices"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/auth"
    "github.com/kataras/iris/v14/rest/errors"
)

// RequireRoles allows the request only when the verified customer
// holds every required role. Register it AFTER VerifyHandler.
func RequireRoles(required ...string) iris.Handler {
    return func(ctx iris.Context) {
        user := auth.GetUser[Customer](ctx)

        for _, role := range required {
            if !slices.Contains(user.Roles, role) {
                errors.PermissionDenied.Message(ctx,
                    "missing required role: "+role)
                return
            }
        }

        ctx.Next()
    }
}

Mount guards per router, in order: authentication first, then authorization:

Go
func (api *AccountAPI) Routing(r iris.Router) {
    // ...
    admin := r.Router("/admin",
        api.sso.VerifyHandler(), RequireRoles("admin"))
    admin.Get("/customers", api.listCustomers)
}

The status codes carry the semantics: a missing or invalid token is 401 (errors.Unauthenticated) because the caller is unknown; a known caller lacking rights is 403 (errors.PermissionDenied). Fine-grained, per-resource decisions (“is this user the owner of order 42?”) belong in the handler or service layer, where the resource is already loaded. Compare against auth.GetUser[Customer](ctx).ID there instead of writing a guard.

The same pattern works for permission strings, plan tiers or any other claim your user type carries; because guards are ordinary handlers, they also slot directly into the builder’s API step:

Go
API("/admin", sso.VerifyHandler(), RequireRoles("admin"),
    NewAdminAPI).

Session-Based Authentication

For browser applications that render pages and keep state on the server, middleware/sessions provides typed sessions with secure-by-default cookies. In v14, sessions.NewOptions returns options with Secure: true, SameSite: Lax and a 24-hour MaxAge; session cookies are always HttpOnly.

Go
package main

import (
    "time"

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

// UserSession is the typed session payload.
type UserSession struct {
    UserID   string
    Username string
    Roles    []string
    SigninAt time.Time
}

func main() {
    // Persist these keys; regenerating them invalidates all sessions.
    hashKey := []byte("hash-key-32-characters-long-str!")  // 32 or 64
    blockKey := []byte("block-key-24-characters!") // 16, 24 or 32

    opts := sessions.NewOptions(hashKey, blockKey)
    opts.MaxAge = 8 * time.Hour
    // Secure=true and SameSite=Lax are the defaults. Loosen only for
    // local plain-HTTP development:
    // opts.Secure = false

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

    app := iris.New()

    site := app.Router("/", sessions.Handler(db, "app_session"))
    site.Post("/signin", signin)
    site.Post("/signout", signout)
    site.Get("/profile", requireUser, profile)

    app.Listen(":8080")
}

sessions.Handler loads (or creates) the session before your handlers run and commits it afterwards when the request changed it: its data, a flash message, its id or its Options. A new session counts as changed. It does not commit when a handler already did, or when the response is an error status. A cookie it cannot decode, because it was signed with other keys or is older than MaxAge, counts as no cookie: the request runs with a new session (IsNew is true) and the commit replaces the cookie. Only a failure of the store itself answers 500.

A request that only reads the session, such as GET /profile above, sends no cookie and writes nothing to the store. That matters when requests overlap: a read that is still running while another request signs the visitor out has nothing to store, so it cannot bring the session back. Two requests that both change the session at the same moment still lose one of the two writes.

That is the rule for the three databases of the package: the cookie one above, and the Redis and Postgres ones below. The session of a Database you write yourself is committed after every request, as before.

The same rule sets the lifetime. The eight hours of MaxAge count from the last change, not from the last request, so a visitor who signs in and then only reads is signed out eight hours after signing in. To keep the session alive on a route that only reads it, call session.Commit(ctx) in that handler: called by hand it saves the session and sends the cookie again, changed or not. A deployment that changes Secure, Path or MaxAge in the session options waits in the same way: it reaches a visitor at their next change of the session, not at their next request.

A read that sends no cookie has one consequence to check. Set-Cookie was what kept such a page out of shared caches, and now nothing does. A reverse proxy that skips responses with Set-Cookie can store one visitor’s page and serve it to the next. When such a proxy caches in front of the application, send Cache-Control: private on every page that depends on the session: ctx.SetHeader("Cache-Control", "private") in the handler. The httpcache middleware is safe without the header as long as its handler is registered before sessions.Handler: it caches a request with cookies only when its key names every one of them (Chapter 14).

Sign-In with Fixation Defense

A session fixation attack plants a known session identifier in the victim’s browser before sign-in, then reuses it once the session becomes privileged. The defense is to rotate the identifier at the moment the privilege level changes, which is exactly what RegenerateID does:

Go
// Account lives in your user store; verifyCredentials performs the
// lookup and bcrypt comparison, as in the provider example above.
type Account struct {
    ID       string
    Username string
    Roles    []string
}

func verifyCredentials(username, password string) (Account, bool) {
    // Query your database, compare the bcrypt hash...
    return Account{}, false
}

type signinRequest struct {
    Username string `json:"username"`
    Password string `json:"password"`
}

func signin(ctx iris.Context) {
    req, ok := ctx.BindJSON[signinRequest]()
    if !ok {
        return // 400 already written.
    }

    account, ok := verifyCredentials(req.Username, req.Password)
    if !ok {
        errors.Unauthenticated.Message(ctx, "invalid credentials")
        return
    }

    session := sessions.Get[UserSession](ctx)
    session.Set(UserSession{
        UserID:   account.ID,
        Username: account.Username,
        Roles:    account.Roles,
        SigninAt: time.Now(),
    })

    // Rotate the session identifier now that the session is
    // privileged. For server-side stores this deletes the record kept
    // under the old ID and re-issues the cookie under a fresh one; for
    // cookie-based sessions (no server-side ID) it is a harmless no-op.
    if err := session.RegenerateID(ctx); err != nil {
        errors.Internal.Err(ctx, err)
        return
    }

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

Set the authenticated data before calling RegenerateID: it persists the session itself, so no extra Commit is required. The Handler commits nothing more after it, so data set after the call is never stored on Redis or Postgres.

Guarding and Sign-Out

Go
func requireUser(ctx iris.Context) {
    session := sessions.Get[UserSession](ctx)
    if session == nil || session.Data.UserID == "" {
        errors.Unauthenticated.Message(ctx, "sign in required")
        return
    }
    ctx.Next()
}

func profile(ctx iris.Context) {
    session := sessions.Get[UserSession](ctx)
    ctx.JSON(session.Data)
}

func signout(ctx iris.Context) {
    session := sessions.Get[UserSession](ctx)
    if err := session.Delete(ctx); err != nil {
        errors.Internal.Err(ctx, err)
        return
    }
    ctx.OK(iris.Map{"message": "signed out"}, nil)
}

Server-Side Stores

The cookie database keeps everything in the (encrypted, authenticated) cookie itself: zero infrastructure, but sessions cannot be revoked server-side and the payload must stay small. For revocable sessions use a server-side store; only an opaque ID travels in the cookie:

Go
// Redis (third-party client: github.com/redis/go-redis/v9).
client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
db := sessions.NewRedisDatabase[UserSession](client, opts)

// PostgreSQL (third-party pool: github.com/jackc/pgx/v5/pgxpool).
db := sessions.NewPostgresDatabase[UserSession](
    sessions.DefaultTableName, conn, opts)

Both implement the same sessions.Database[T] interface, so the sessions.Handler registration and every handler above stay unchanged. With a server-side store, RegenerateID performs a real rotation.

Identity Server and OAuth2

Two heavier subpackages build on the auth SDK. Both are overviews here; each ships with its own documentation.

First, the terms. An identity provider is a service whose only job is knowing who users are, so many applications can share one sign-in. OAuth 2.0 is the standard protocol those applications use to obtain access on a user’s behalf without ever seeing the user’s password: the user authenticates at the identity provider, which hands the application a token with agreed-upon scope. Its different flows are choreographies for different client shapes: a web app that can redirect the browser (authorization code), a TV or CLI with no browser (device code), a trusted backend service (password grant, machine-to-machine).

auth/identity: a Complete Identity Server

auth/identity is a production identity server you mount into an Iris application. It is backed by PostgreSQL (via github.com/kataras/pg) and provides, out of the box:

  • User management under /u: signup, signin, refresh, signout, password reset, soft delete and restore, bulk operations, paginated and filtered listing, and user groups.
  • Declarative user schema validation configured in YAML (text, number, email, date, phone, bool, uuid, slice, map field types).
  • OAuth2 client applications: user applications (authorization code and device code flows) and server applications (password grant, machine-to-machine) via AddUserApplication, which takes an identity.UserApplicationRegistration value, and AddServerApplication.
  • Client management for an admin client: AddAdminApplication registers a server application that also holds the clients:* scopes, and with its token you create, list, count, read and delete clients, issue a client a new token and revoke its tokens under /clients. The SDK wraps these as AdminCreateClient, AdminListClients, AdminCountClients, AdminGetClient, AdminDeleteClient, AdminIssueClientToken and AdminRevokeClientTokens.
  • The OAuth2 endpoints of the section below, plus /.well-known/jwks.json, optional Redis-backed response caching and usage metering.
Go
import "github.com/kataras/iris/v14/auth/identity"

identityServer := identity.Load("identity.yml")
app.Router("/auth", identityServer)

A typed Go client for the whole HTTP API lives in auth/identity/sdk: sdk.New[Profile](opts) gives admin operations (signup, list, partial updates), UserSignin via the OAuth2 password grant, and sdk.NewAuth[Profile], a ready *auth.Auth[Profile] that verifies identity-server tokens in your own services. See the auth package README (auth/README.md) in the framework repository for details.

auth/oauth2: an OAuth2 Authorization Server

auth/oauth2 implements the server side of OAuth 2.0, generic over your user data type. It supports the authorization code flow (with a built-in or custom consent page), the device code flow for browserless devices, the password grant for trusted server applications, and refresh token rotation. Mounting the server registers:

  • GET /authorize, GET|POST /consent, GET /consent/revoke
  • POST /device/code
  • POST /token
  • POST /token/introspect

Client and code stores have in-memory and PostgreSQL implementations. Authorization is expressed as resource-action policies (oauth2.ResourcePolicy) enforced by the oauth2.AllowPolicies middleware, with optional Open Policy Agent (Rego) integration under auth/oauth2/opa. The package README (auth/oauth2/README.md) walks through a complete server and all four client flows.

Summary

  • Pick the lightest tool that fits: basicauth for internal surfaces, the auth SDK for user-facing token APIs, middleware/jwt for raw token verification, middleware/sessions for browser state, and auth/identity/auth/oauth2 when you need a standalone identity provider.
  • auth.Auth[T] covers the full token lifecycle with five handlers and one middleware; you contribute the YAML key configuration and a Provider[T] over your user store. Register the instance as a service and apply VerifyHandler per router, with optional VerifyUserFunc claims checks; read the user with auth.GetUser[T].
  • Authorization is plain handlers: guards that call ctx.Next() or respond with errors.PermissionDenied (403), distinct from errors.Unauthenticated (401).
  • v14 defaults are secure: the JWT verifier reads the Authorization header only, and session cookies default to Secure, SameSite=Lax and HttpOnly. Call RegenerateID after sign-in to defeat session fixation.
  • Testing authentication flows with httptest is covered in Chapter 16; broader hardening guidance lives in Chapter 18.

Further Reading

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