Auth

JWT Providers

Issue and validate HS256 tokens, validate GoTrue tokens, or plug in your own credential scheme.

The app takes an ezz.AuthProvider. JWT support is one layer below that, behind a smaller interface:

type JWTProvider interface {
    ValidateToken(token string) (*Claims, error)
    GenerateToken(claims *Claims) (string, error)
}

auth.NewJWTAuthProvider adapts any implementation to the app.

Claims

type Claims struct {
    Subject   string         `json:"sub"`
    Issuer    string         `json:"iss"`
    Audience  string         `json:"aud"`
    ExpiresAt int64          `json:"exp"`
    IssuedAt  int64          `json:"iat"`
    NotBefore int64          `json:"nbf"`
    ID        string         `json:"jti"`
    Custom    map[string]any `json:"custom,omitempty"`
}

Valid() checks exp and nbf against the current time. Everything application-specific goes in Custom, which is where the role and roles claims are read from when no resolver is configured.

Issue and validate your own tokens

HMACProvider signs and verifies HS256 tokens with no external dependency.

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

A login handler mints the token:

app.POST("/login", ezz.H(func(ctx *ezz.Context, req LoginRequest) (*LoginResponse, error) {
    user, ok := accounts.Verify(ctx, req.Email, req.Password)
    if !ok {
        return nil, ezz.Unauthorized("invalid email or password")
    }

    expires := time.Now().Add(1 * time.Hour)
    token, err := jwt.GenerateToken(&auth.Claims{
        Subject:   user.ID,
        ExpiresAt: expires.Unix(),
        Custom: map[string]any{
            "email": user.Email,
            "roles": user.Roles,
        },
    })
    if err != nil {
        return nil, ezz.InternalServerError("could not issue token")
    }
    return &LoginResponse{Token: token, ExpiresAt: expires}, nil
})).Public()

GenerateToken fills in the issuer from the provider and iat from the clock when they are unset. Validated tokens are cached in memory (1000 entries, keyed by the token string, expiring with the token) so a burst of requests from the same client verifies the signature once.

Keep the secret out of source control and rotate it deliberately. Changing it invalidates every token immediately, since the provider holds a single key with no kid lookup.

Validate GoTrue tokens

GoTrue and Supabase Auth sign with HS256 using the project's JWT secret, so validation is the same mechanism with GoTrue's claim layout on top.

gt := auth.NewGoTrueProvider("http://gotrue:9999", os.Getenv("GOTRUE_JWT_SECRET"))
app := ezz.New().Auth(auth.NewJWTAuthProvider(gt))

Beyond the standard fields, these claims are copied into Claims.Custom: email, phone, role, session_id, is_anonymous, user_metadata, app_metadata, identities, and factors.

user := ctx.User().(auth.User)
claims := user.GetClaims()
email, _ := claims.Custom["email"].(string)
meta, _ := claims.Custom["user_metadata"].(map[string]any)

GetUser(token) calls GoTrue's GET /user when you need the full record rather than the claims:

gotrueUser, err := gt.GetUser(token)

GenerateToken returns an error here, since only GoTrue issues its tokens.

GoTrue's role claim comes from auth.users.role and is authenticated for every normal user. It is not an application role. See Roles and permissions.

If you want ezz to be the only public entry point and expose typed /auth/* routes, use the proxy instead of the bare provider. See GoTrue proxy.

Implement a custom JWT provider

Use this when your tokens are RS256, come from an external issuer with a JWKS endpoint, or need claims the built-in providers do not parse.

type OIDCProvider struct {
    keys *keyfunc.JWKS
}

func (p *OIDCProvider) ValidateToken(token string) (*auth.Claims, error) {
    parsed, err := jwt.Parse(token, p.keys.Keyfunc)
    if err != nil || !parsed.Valid {
        return nil, fmt.Errorf("invalid token: %w", err)
    }
    mc := parsed.Claims.(jwt.MapClaims)

    claims := &auth.Claims{Custom: map[string]any{}}
    claims.Subject, _ = mc["sub"].(string)
    claims.Issuer, _ = mc["iss"].(string)
    if exp, ok := mc["exp"].(float64); ok {
        claims.ExpiresAt = int64(exp)
    }
    for _, k := range []string{"email", "roles", "groups"} {
        if v, ok := mc[k]; ok {
            claims.Custom[k] = v
        }
    }
    return claims, claims.Valid()
}

func (p *OIDCProvider) GenerateToken(*auth.Claims) (string, error) {
    return "", errors.New("tokens are issued by the identity provider")
}

app := ezz.New().Auth(auth.NewJWTAuthProvider(&OIDCProvider{keys: keys}))

Authenticate something other than a JWT

For API keys, opaque sessions, or client certificates, implement ezz.AuthProvider directly. Return a value with HasRole and HasPermission so the route rules keep working.

type APIKeyProvider struct{ store *KeyStore }

func (p *APIKeyProvider) Authenticate(r *http.Request) (any, error) {
    key := r.Header.Get("X-API-Key")
    if key == "" {
        return nil, errors.New("missing api key")
    }
    client, err := p.store.Lookup(r.Context(), key)
    if err != nil {
        return nil, err
    }
    return client, nil
}

type Client struct {
    Name   string
    Scopes []string
}

func (c *Client) HasRole(role string) bool            { return slices.Contains(c.Scopes, role) }
func (c *Client) HasPermission(perm string) bool      { return slices.Contains(c.Scopes, perm) }

app := ezz.New().Auth(&APIKeyProvider{store: keys})
app.GET("/metrics", metrics).Auth(ezz.Permission("metrics:read"))

To support several credential types, try each in turn:

type MultiProvider struct{ providers []ezz.AuthProvider }

func (m *MultiProvider) Authenticate(r *http.Request) (any, error) {
    var last error
    for _, p := range m.providers {
        user, err := p.Authenticate(r)
        if err == nil {
            return user, nil
        }
        last = err
    }
    return nil, last
}

Authenticate in middleware instead

App-level auth runs before the middleware chain, which means rejections skip your logging and CORS middleware. When you need those on 401 responses, or when only part of the app is protected, use the middleware form:

protected := app.Group("/api").Use(auth.JWTMiddleware(jwt))
open := app.Group("/public").Use(auth.OptionalJWTMiddleware(jwt))
HelperBehaviour
auth.JWTMiddleware(provider)Rejects a missing or invalid token with 401
auth.OptionalJWTMiddleware(provider)Continues without a user when the token is missing or invalid

Both put the claims and a *auth.BasicUser on the request context, readable with auth.GetClaims(ctx), auth.GetUserID(ctx), and auth.UserFromContext(ctx). Note that these middlewares reply with plain text and do not consult route rules, so combine them with checks in your handlers, or set DefaultAuth(nil) and keep route rules for the app-level provider.

Next steps

Copyright © 2026