Skip to content

Authentication

This recipe is dygo g auth: login and tokens, with routes protected one by one. For an API that is locked by default, with rules, refresh tokens, an identity provider and test helpers, use dygo g security instead: see the security recipe.

The quickest path is the generator. It writes a complete, tested auth module that uses only the standard library, which you then own and change.

Terminalsh
dygo g auth

You get auth/ (or src/modules/auth/ in a modular project), imported in the root module:

Route Does
POST /auth/register {"email","password"}, 201 with the user, 409 if the email is taken
POST /auth/login 200 with {"accessToken","tokenType":"Bearer","expiresAt"}, 401 for a wrong email or password
GET /auth/me the signed in user, 401 without a valid token

Passwords are hashed with PBKDF2-HMAC-SHA256 and tokens are HS256 JWTs. Set AUTH_SECRET (32 or more characters, openssl rand -hex 32); it is required when APP_ENV=production.

The module is global, so any controller can use its route options without importing the module:

func (c *Controller) Routes(r dygo.Router) {
r = r.Prefix("/orders").Use(auth.Required()) // every route of the controller
r.Get("/", dygo.HandleNoInput(c.List))
r.Delete("/{id}", dygo.Handle(c.Delete), auth.Roles("admin")) // and this one needs a role
}
func (c *Controller) List(ctx context.Context) ([]Order, error) {
claims, _ := auth.UserFrom(ctx) // set by the guard
return c.svc.ListFor(ctx, claims.UserID())
}

Without a token the client gets 401; with a token but without the role, 403.

The generated guard is ordinary Dygo code you can read in auth/guard.go:

var rolesKey = meta.NewKey[[]string]("auth.roles")
// Required protects a route, or every route of a controller on Router.Use.
func Required() dygo.RouteOption { return dygo.Guards(dygo.Ref[*Guard]()) }
// Roles protects a route and requires one of roles.
func Roles(roles ...string) dygo.RouteOption {
return dygo.Options(Required(), rolesKey.Set(roles))
}
func (g *Guard) CanActivate(ec dygo.ExecutionContext) (bool, error) {
token, ok := strings.CutPrefix(ec.HTTP().Request().Header.Get("Authorization"), "Bearer ")
if !ok {
return false, errs.Unauthorized("missing bearer token")
}
c, err := g.tokens.Verify(token)
if err != nil {
return false, errs.Unauthorized(err.Error())
}
ec.SetContext(context.WithValue(ec.Context(), claimsKey{}, c))
want := meta.GetAllAndMerge(ec, rolesKey)
if len(want) == 0 || slices.ContainsFunc(want, func(r string) bool { return slices.Contains(c.Roles, r) }) {
return true, nil
}
return false, nil
}
  • dygo.Ref[*Guard]() makes the guard a provider, so it can inject the token service, and it is built once at boot.
  • Roles are typed route metadata. GetAllAndMerge joins the controller’s and the route’s values, like Reflector.getAllAndMerge.
  • The claims travel on the request context, which is how Go passes request values; the handler reads them with auth.UserFrom(ctx).

dygo g security does this for you, with rules and tests (security recipe). By hand: to require a token everywhere and opt routes out, flip the logic: register the guard globally and mark public routes with metadata.

var publicKey = meta.NewKey[bool]("auth.public")
func Public() dygo.RouteOption { return publicKey.Set(true) }
func (g *Guard) CanActivate(ec dygo.ExecutionContext) (bool, error) {
if public, _ := publicKey.Get(ec); public {
return true, nil
}
// ... the token check from above
}
// in the auth module's Providers:
dygo.Provide(NewGuard, dygo.Group(dygo.AppGuards)) // like APP_GUARD

Remember the health routes: health.Module(health.Options{Use: []dygo.RouteOption{auth.Public()}}).

Users are kept in memory in auth/users.go. Replace the Users type with a repository over your database (see the SQL recipe); the service only calls its methods.

dygo g security -oidc generates this with the standard library (security recipe). By hand: for OIDC (Auth0, Keycloak, Google, Entra ID), keep the guard and replace Tokens.Verify with verification against the provider’s JWKS, for example with github.com/coreos/go-oidc. The route options, the roles metadata and UserFrom stay the same, so no controller changes.

A module whose routes use auth.Required() needs the auth module in its tests, or the boot fails with *auth.Guard is not visible:

srv := dygotest.New(&dygo.Module{Name: "app", Imports: []*dygo.Module{auth.Module, orders.Module}}).
CompileHTTP(t)
var tok struct{ AccessToken string }
srv.POST("/auth/register").JSON(creds).Do(t).Status(t, 201)
srv.POST("/auth/login").JSON(creds).Do(t).Status(t, 200).JSON(t, &tok)
srv.GET("/orders").Header("Authorization", "Bearer "+tok.AccessToken).Do(t).Status(t, 200)
srv.GET("/orders").Do(t).Status(t, 401)
Route Does
POST /auth/register {"email","password"}, 201 with the user, 409 if the email is taken
POST /auth/login 200 with {"accessToken","tokenType","expiresAt","refreshToken"}, 401 for a wrong email or password
POST /auth/refresh {"refreshToken"}, 200 with a new pair; the old refresh token is spent
POST /auth/logout {"refreshToken"}, 204; the login ends
GET /auth/me the signed in user
any other route 401 without a valid token
GET /orders 401 {"statusCode":401,"error":"Unauthorized","message":"missing bearer token"}
GET /orders (token) 200
DELETE /orders/1 (user) 403 {"statusCode":403,"error":"Forbidden","message":"access denied"}

A 401 carries WWW-Authenticate: Bearer realm="api". A 403 never says which role was missing. The reason a token was refused goes to the debug log, never to the client.

  • Access token: an HS256 JWT signed with AUTH_SECRET, valid for AUTH_TOKEN_TTL (15 minutes). The header must be exactly HS256, so "alg": "none" and algorithm swaps are refused. iss, exp and iat are checked with 30 seconds of clock skew.
  • Refresh token: 32 random bytes, stored only as their SHA-256, valid for AUTH_REFRESH_TTL (30 days). Each refresh spends the token and returns a new one. Sending a spent token again ends the whole login, for the user and for whoever stole the copy.
  • Logout ends the login. Access tokens that were already issued work until they expire, which is why they are short.
  • Passwords: PBKDF2-HMAC-SHA256 with 600,000 iterations. A hash made with fewer iterations is upgraded at the next login. Login takes the same time for an unknown email and for a wrong password.
  • Rate limit: register, login and refresh allow 10 calls a minute per client IP, then answer 429.
  • Speed: a token that passed every check is remembered until it expires, so a request with a known token costs well under a microsecond more than one without security, with own tokens or -oidc.

An app uses g auth or g security, not both; g security stops if it finds an auth/ package. To switch:

  1. Delete auth/ and its import in the root module.
  2. Run dygo g security.
  3. Remove auth.Required() (routes are locked by default). Replace auth.Roles with security.Roles and auth.UserFrom(ctx) with security.CurrentUser(ctx); the user ID is p.Subject. Open the public routes in rules.go or with security.Public().
  4. In tests, replace logins or auth.BearerFor with securitytest.Setup(t) and .With(securitytest.Admin()).

go build points at every line to change. examples/academy was moved this way.

A stateless API does not need form login, sessions, remember-me cookies, CSRF tokens or login pages, so they are left out. Also not included: an authorization server (use your identity provider), DPoP and PAR.