Security

Authentication

JWT issuing and the Bearer middleware: stateless auth.
import "github.com/gp-system/gpsystem/auth"

The auth package provides two things: a token issuer (JWT signing and verification) and a middleware that turns the incoming Authorization: Bearer <token> header into an rbac.Identity in the request context. From there on, every authorization decision (the role and permission checks, the policies) happens over the Identity, independent of the token.

Authentication here is built around a stateless JWT: the login endpoint hands out a signed token, every subsequent request carries it, and the server keeps no session store: the token itself carries the subject, roles and permissions.

The package builds on golang-jwt/jwt/v5 and does not hide it: claims types implement the jwt.Claims interface, and a custom claims struct embeds jwt.RegisteredClaims. The kit centralizes here rather than abstracting: your JWT-ecosystem knowledge transfers one to one.

Configuration

auth.Config loads from the environment; in a generated project's config you add it under the // gpsystem:config anchor with the JWT_ prefix:

// internal/platform/config/config.go
type Config struct {
    Server server.Config
    DB     dbx.Config  `envPrefix:"DB_"`
    JWT    auth.Config `envPrefix:"JWT_"`
    // gpsystem:config
}
Env variableFieldDefault
JWT_SECRETSecretrequired
JWT_ACCESS_TOKEN_TTLAccessTTL15m
JWT_REFRESH_TOKEN_TTLRefreshTTL168h (7 days)
JWT_ISSUERIssuerempty (issuer check disabled)
JWT_AUDIENCEAudienceempty (audience check disabled)
Set Issuer and Audience, and use a distinct Secret per service and per environment: that way a token minted for another service or for staging cannot be replayed against the production API. When Issuer/Audience are set, parsing requires a matching iss/aud claim. Full env reference: Configuration.

Issuing tokens: the login flow

The kit's standard claims shape is DefaultClaims: compatible with the rbac.Identity fields, embedding jwt.RegisteredClaims:

type DefaultClaims struct {
    Username    string   `json:"username,omitempty"`
    Roles       []string `json:"roles,omitempty"`
    Permissions []string `json:"permissions,omitempty"`
    jwt.RegisteredClaims
}

NewDefaultClaims stamps the registered claims (iat, exp, iss, sub, plus aud when configured) from the config; the TokenIssuer signs and parses the token. This is what the shop's login service looks like, sketched:

// internal/modules/shop/surfaces/admin/service/auth.go (sketch)
type Service struct {
    jwt    auth.Config
    issuer *auth.TokenIssuer[auth.DefaultClaims]
    users  UserRepo
    tokens RefreshTokenRepo
}

func New(jwtCfg auth.Config, users UserRepo, tokens RefreshTokenRepo) *Service {
    return &Service{
        jwt:    jwtCfg,
        issuer: auth.NewTokenIssuer[auth.DefaultClaims](jwtCfg),
        users:  users,
        tokens: tokens,
    }
}

func (s *Service) Login(ctx context.Context, email, password string) (LoginResult, error) {
    user, err := s.users.FindByEmail(ctx, email)
    if err != nil || !user.CheckPassword(password) {
        return LoginResult{}, ErrInvalidCredentials
    }

    claims := auth.NewDefaultClaims(s.jwt, user.ID, user.Name,
        user.Roles, user.Permissions) // iat/exp/iss/sub stamped from config
    access, err := s.issuer.Sign(claims)
    if err != nil {
        return LoginResult{}, err
    }

    refresh, err := auth.NewRefreshToken() // 32 bytes crypto/rand, hex
    if err != nil {
        return LoginResult{}, err
    }
    expiresAt := time.Now().Add(s.jwt.RefreshTTL)
    if err := s.tokens.Store(ctx, user.ID, sha256Hex(refresh), expiresAt); err != nil {
        return LoginResult{}, err
    }

    return LoginResult{AccessToken: access, RefreshToken: refresh}, nil
}
Refresh token handling is your schema: NewRefreshToken only returns a cryptographically random string, the kit does not store it. Server-side, store only its hash (as with passwords), and use RefreshTTL as the expiry (the sha256Hex above is your own helper).

The middleware

The middleware performs three steps on every request: it extracts the Bearer token from the Authorization header, validates it with the TokenIssuer (signature, expiry, issuer/audience), then puts the *rbac.Identity built from the claims into the context. A failure at any step yields a 401 problem+json response; code running after the middleware reads the caller back with rbac.FromContext.

This is how the shop's admin surface goes behind JWT + role protection: you add the middleware inside the generated Register<Surface> function, without touching the other surfaces (see add surface):

func RegisterAdmin(router fiber.Router, deps Dependencies) {
    adminSvc := adminservice.New()
    adminHandler := adminhttp.New(adminSvc)
    adminPolicies := adminpolicy.New()
    adminGroup := router.Group("/admin/shop")
    adminGroup.Use(auth.Middleware(deps.Auth), rbac.RequireRole("admin"))
    shopadmingen.RegisterHandlersWithOptions(adminGroup, /* ... generated strict-handler wiring unchanged ... */)
}

You pass auth.Config in through the module's Dependencies struct (an Auth auth.Config field at the // gpsystem:dependencies anchor, Auth: cfg.JWT in main.go). This way register.go never depends on the platform config.

Mixed surfaces: public and protected operations side by side

The middleware rejects every tokenless request with 401; for an all-protected surface (like admin) that is exactly what you want. The shop's api surface is mixed, though: product browsing is anonymous, while the order operations require an identity. In that case, make token parsing conditional, and leave the 401 for tagged operations to the policy enforcer, which rejects without an identity anyway:

authOptional := auth.Middleware(deps.Auth)
apiGroup.Use(func(c fiber.Ctx) error {
    if c.Get(fiber.HeaderAuthorization) == "" {
        return c.Next() // anonymous browsing passes; tagged operations get 401 from the enforcer
    }
    return authOptional(c) // if a token is present, it must be valid
})

Custom claims

When DefaultClaims is not enough (say you carry a tenant identifier in the token), define your own claims type and use the MiddlewareFor / MiddlewareForHTTP pair to state how an Identity is built from it:

type MyClaims struct {
    TenantID string `json:"tenant"`
    jwt.RegisteredClaims
}

issuer := auth.NewTokenIssuer[MyClaims](cfg.JWT)
mw := auth.MiddlewareFor(issuer, func(c MyClaims) (*rbac.Identity, error) {
    return &rbac.Identity{
        Subject: c.Subject,
        Extra:   map[string]any{"tenant": c.TenantID},
    }, nil
})

MiddlewareForHTTP is the same in func(http.Handler) http.Handler shape; the built-in Middleware/MiddlewareHTTP are exactly this pair, pre-wired with DefaultClaims. The JWT parsing and the Identity contract are shared between the two engines.

HS256 and secret management

Signing is HS256 (shared secret). Parsing pins the algorithm (WithValidMethods), requires an expiry (WithExpirationRequired), and when configured, checks issuer and audience, so alg-confusion attacks and unsigned tokens are rejected. The TokenIssuer API is claims-generic, so asymmetric algorithms can be added later without breaking consumers.

The secret should be long and random (e.g. openssl rand -hex 32) and come from a secret manager: never commit it to the repo. .env is for local development only; see the configuration page.

The next layer: role and permission checks over the Identity the token became, and per-request policies.

Patterns used

Generics over the claims type (TokenIssuer[C jwt.Claims], MiddlewareFor[C]/MiddlewareForHTTP[C]): Design patterns.

Copyright © 2026