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 building | Reach for |
|---|---|
| An internal or admin surface with a handful of operators | middleware/basicauth |
| Another service, a cron job or a webhook sender, with no user to sign in | middleware/apikey |
| A user-facing API with access and refresh tokens | the auth package (auth.Auth[T]) |
| A resource server that only verifies tokens issued elsewhere | middleware/jwt |
| A browser application with server-side state | middleware/sessions |
| A standalone identity provider for many applications | auth/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:
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:
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:
# 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: operatorauth := 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:
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:
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:
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:
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:
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:
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:
# 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:
// 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:
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”:
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:
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:
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:
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:
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:
// 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:
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.
Cookie Storage for Browser Clients
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.
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 withctx.Logout(). - Both sides support AES-GCM payload encryption:
signer.WithEncryption(key, nil)andverifier.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,typand nothing else. A token from an identity provider such as Auth0 also names its key in akidfield, so it is refused withjwt.ErrTokenAlguntil you set the verifier’s optionalHeaderValidatorfield. TheValidateHeadermethod of agithub.com/kataras/jwtkey set fits there. It loads the issuer’s public keys from its JWKS URL and picks one by thekidof each token:
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.ValidateHeaderRole 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:
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:
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:
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.
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:
// 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
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:
// 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 anidentity.UserApplicationRegistrationvalue, andAddServerApplication. - Client management for an admin client:
AddAdminApplicationregisters a server application that also holds theclients:*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 asAdminCreateClient,AdminListClients,AdminCountClients,AdminGetClient,AdminDeleteClient,AdminIssueClientTokenandAdminRevokeClientTokens. - The OAuth2 endpoints of the section below, plus
/.well-known/jwks.json, optional Redis-backed response caching and usage metering.
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/revokePOST /device/codePOST /tokenPOST /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:
basicauthfor internal surfaces, theauthSDK for user-facing token APIs,middleware/jwtfor raw token verification,middleware/sessionsfor browser state, andauth/identity/auth/oauth2when 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 aProvider[T]over your user store. Register the instance as a service and applyVerifyHandlerper router, with optionalVerifyUserFuncclaims checks; read the user withauth.GetUser[T].- Authorization is plain handlers: guards that call
ctx.Next()or respond witherrors.PermissionDenied(403), distinct fromerrors.Unauthenticated(401). - v14 defaults are secure: the JWT verifier reads the
Authorizationheader only, and session cookies default toSecure,SameSite=Laxand HttpOnly. CallRegenerateIDafter sign-in to defeat session fixation. - Testing authentication flows with
httptestis covered in Chapter 16; broader hardening guidance lives in Chapter 18.
Further Reading
- MDN: HTTP authentication:
the
Authorizationheader, challenges and the Basic scheme. - MDN: Using HTTP cookies:
how cookies work, with
HttpOnly,SecureandSameSiteexplained. - RFC 7519 — JSON Web Token (JWT):
the token structure and the registered claims (
exp,iss,sub,jti) behindStandardClaims. - OWASP Authentication Cheat Sheet: practical guidance on sign-in flows, credential handling and errors.
- OWASP Password Storage Cheat Sheet: current recommendations for bcrypt and friends, including cost factors.
- OWASP Session Management Cheat Sheet:
session identifiers, expiration and the fixation attack that
RegenerateIDdefeats.