Auth

Overview

How authentication is wired into the app, and what happens on every request.

Authentication in ezz is a single interface plus a per-route rule. You register one provider on the app; each route carries a rule saying what it needs.

type AuthProvider interface {
    Authenticate(r *http.Request) (any, error)
}

The provider turns a request into a user value of any type. The rule decides whether that user may proceed.

Turn authentication on

jwt := auth.NewHMACProvider(os.Getenv("JWT_SECRET"), "my-service")
app := ezz.New().Auth(auth.NewJWTAuthProvider(jwt))

From that point, every route requires a valid token, because the app's default rule is ezz.Authenticated(). Public endpoints are explicit:

app.GET("/health", health).Public()
app.POST("/login", login).Public()
app.GET("/me", profile)                             // needs a token
app.GET("/admin", panel).Auth(ezz.Role("admin"))    // needs the admin role
Without app.Auth(...), no rule is enforced, including ezz.Role(...). The check is skipped entirely when there is no provider, so a service with route rules but no provider serves everything to everyone. Register the provider in the same place you build the app.

To invert the default and opt into protection per route:

app := ezz.New().Auth(provider).DefaultAuth(nil)

Follow a request through

For a route that is not public:

  1. Authenticate(r) runs. An error means 401 and the request stops.
  2. The route's rule gets the returned user. Check returning nil lets the request continue.
  3. If Check fails, ezz looks at the user for a resolver error first. If there is one, the response is 500; otherwise it is 403, or the status carried by the returned *ezz.HTTPError.
  4. The user is attached to the request context, so ctx.User() and ezz.UserFromContext can reach it.
  5. The middleware chain and your handler run.

Public routes skip all of it, so Authenticate is never called and ctx.User() is nil.

This check happens before the middleware chain, so 401 and 403 responses are not passed through logging, recovery, or CORS middleware. See the ordering notes in Middleware.

What the JWT provider gives you

auth.NewJWTAuthProvider(jwtProvider) adapts any auth.JWTProvider to the app interface. It:

  • pulls the token from Authorization: Bearer <token>, then the token query parameter, then an access_token cookie;
  • validates it with the underlying provider;
  • logs a failure through the audit logger, when one is configured;
  • returns an *auth.AuthorizedUser, which exposes the claims and answers role and permission checks.
provider := auth.NewJWTAuthProvider(jwt).
    WithResolver(myResolver{db: db}).          // roles and permissions from your store
    WithAuditLogger(auth.NewSlogAuditLogger(nil))

app := ezz.New().Auth(provider)

Reach the user in a handler

app.GET("/me", ezz.HFunc(func(ctx *ezz.Context) (*Profile, error) {
    user, ok := ctx.User().(auth.User)
    if !ok {
        return nil, ezz.Unauthorized("no user on request")
    }
    return loadProfile(ctx, user.ID())
}))

auth.User is the interface the built-in user values satisfy:

type User interface {
    ID() string
    GetClaims() *Claims
    GetAttribute(key string) (any, bool)
}

For role checks inside a handler, assert the narrower interface instead of a concrete type:

if rc, ok := ctx.User().(interface{ HasRole(string) bool }); ok && rc.HasRole("admin") {
    // admin-only branch
}

Pick your approach

SituationUse
You issue your own HS256 tokensauth.NewHMACProvider
GoTrue or Supabase issues tokensauth.NewGoTrueProvider or the GoTrue proxy
Roles and permissions live in your databaseAn authorization resolver
API keys, sessions, or mTLSImplement ezz.AuthProvider yourself

Next steps

Copyright © 2026