JWT Providers
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.
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.
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))
| Helper | Behaviour |
|---|---|
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
- Roles and permissions to authorize from your own data.
- Audit logging to record authentication failures.