Skip to content

Authenticate requests

gas/auth provides four credential types behind one interface. Core declares gas.Authenticator, gas.Authorizer, and gas.PrincipalRevoker; the module implements them and adds middleware.

Terminal window
go get github.com/gasmod/gas/auth

jwt

Stateless, HS256 or RS256. No database.

session

Server-side sessions with a cookie. Revocable.

apikey

Hashed keys with scopes, for machine callers.

token

Single-use, expiring. Magic links, verification, resets.
main.go
// Start from DefaultConfig: it fills in SigningMethod and Expiry, and a
// zero Expiry fails validation.
jwtCfg := jwt.DefaultConfig()
jwtCfg.JWT.SigningKey = "a-signing-key-of-at-least-32-bytes!!"
app := gas.NewApp(
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// session, apikey and token persist credentials and own migrations.
gas.WithSingletonService[gas.DatabaseProvider](database.New()),
gas.WithSingletonService[gas.MigrationManager](migrate.New()),
gas.WithSingletonService[*jwt.Service](jwt.New(jwt.WithConfig(jwtCfg))),
gas.WithSingletonService[*session.Service](session.New()),
)

Build configs from DefaultConfig(). A bare &jwt.Config{} literal leaves Expiry at zero, which fails validation at startup.

auth.Middleware runs an authenticator and puts the resulting gas.Principal on the request context. auth.Chain tries several in order, so one group can accept a JWT, a session cookie, or an API key.

// Middleware turns a request into a gas.Principal and stores it on the
// context. Chain tries each authenticator in order, so one route group can
// accept a JWT, a session cookie, or an API key.
func protect(router *gas.Router, jwtSvc *jwt.Service, sessSvc *session.Service, keySvc *apikey.Service) {
chain := auth.Chain{jwtSvc, sessSvc, keySvc}
router.Route("/api", func(sub *gas.Router) {
sub.UseMiddlewareFunc(auth.Middleware(chain))
sub.Handle("notes", http.MethodGet, "/notes", listNotes)
})
}

Add auth.RequireScheme(auth.SchemeSession) when a route must insist on one specific credential type, for example a settings page that a long-lived API key should not reach.

// Downstream, the principal is on the context. Metadata is read type-safely.
func listNotes(ctx gas.Context) error {
p := gas.PrincipalFromContext(ctx)
if p == nil {
return gas.Unauthorized("sign in to continue")
}
if role, ok := gas.MetadataValue[string](p.Metadata(), "role"); ok && role != "admin" {
return gas.Forbidden("admins only")
}
return ctx.JSON(http.StatusOK, map[string]string{"subject": p.Subject()})
}

A Principal carries Subject() (the stable user id), Scheme() (jwt, session, apikey), CredentialID() (the specific session or key), and metadata. gas.MetadataValue[T] reads metadata without an unchecked type assertion.

// Sign a token after verifying a password, then hand it to the client.
func issueToken(jwtSvc *jwt.Service, userID string) (string, error) {
return jwtSvc.Sign(userID, map[string]any{"role": "admin"})
}

Sessions are created with Create(ctx, subject, meta, r) and written with SetCookie. API keys come from Generate(ctx, subject, name, scopes, opts...), which returns the plaintext key exactly once; only a SHA-256 hash is stored. Single-use tokens come from Issue and are consumed by Verify, which fails on the second attempt.

By default a failed authentication writes a plain 401. To return the unified error shape instead, take over the response:

// By default the middleware writes a plain 401. WithOnError takes over the
// response, which is how you return the unified JSON error shape instead.
func jsonUnauthorized() auth.MiddlewareOption {
return auth.WithOnError(func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
})
}

session and apikey implement gas.PrincipalRevoker: Revoke kills one credential, RevokeAll every credential for a subject, and RevokeAllByScheme every credential of one type. JWTs are stateless and cannot be revoked individually, which is the trade you accept for not touching a database on every request. Keep their expiry short if that matters.

API key operations can join your own transaction. apiKeyService.WithTx(tx) returns a provider scoped to that transaction, so creating a user and their first key either both happen or neither does.

session, apikey, and token support PostgreSQL, MySQL, and SQLite. The dialect is chosen automatically from the driver name.

authtest provides MockAuthenticator, MockAuthorizer, and MockRevoker, each recording calls and delegating to an optional Fn field, so a protected handler can be tested without issuing real credentials. See Test a service.