Multi-Guard Auth
Sometimes a single route must accept both session-authenticated browser users and API clients sending a Bearer token. Nimbus does not ship a combined guard out of the box, but building one is straightforward using the auth.Guard interface and the standard middleware primitives.
The Guard interface
Any guard implements three methods. User(ctx) returns nil, nil when the guard does not find a user โ not an error. Multi-guard logic exploits this: try each guard in order and stop at the first non-nil user.
type Guard interface {
User(ctx context.Context) (User, error)
Login(ctx context.Context, user User) error
Logout(ctx context.Context) error
}
AnyGuard โ try multiple guards in order
Create a middleware that walks a list of guards and authenticates with the first that returns a user. Drop this into app/middleware/any_guard.go:
package middleware
import (
"github.com/CodeSyncr/nimbus/auth"
"github.com/CodeSyncr/nimbus/http"
"github.com/CodeSyncr/nimbus/router"
)
// AnyGuard tries each guard in order. The first one that returns a non-nil
// user wins. If none succeed, it returns 401 JSON (or redirects if redirectTo
// is non-empty โ useful for web + API hybrid routes).
func AnyGuard(redirectTo string, guards ...auth.Guard) router.Middleware {
return func(next router.HandlerFunc) router.HandlerFunc {
return func(c *http.Context) error {
for _, g := range guards {
user, err := g.User(c.Request.Context())
if err != nil {
return err // hard error โ stop immediately
}
if user != nil {
req := c.Request.WithContext(auth.WithUser(c.Request.Context(), user))
c.Request = req
return next(c)
}
}
// No guard authenticated the request
if redirectTo != "" {
c.Redirect(http.StatusFound, redirectTo)
return nil
}
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "unauthorized",
})
}
}
}
Wiring it in routes
Create both guards, then pass them to AnyGuard. The order determines priority โ web session is tried first, then the API Bearer token:
import (
"github.com/CodeSyncr/nimbus/auth"
"nimbus-starter/app/middleware"
"nimbus-starter/app/models"
)
func RegisterRoutes(app *nimbus.App) {
db := app.Container.MustMake("db").(*nimbus.DB)
// Session guard (web โ cookie / session store)
sessionGuard := auth.NewSessionGuardWithLoader(
auth.UserLoaderFunc(func(ctx context.Context, id string) (auth.User, error) {
var u models.User
if err := db.First(&u, id).Error; err != nil {
return nil, nil
}
return &u, nil
}),
)
// Token guard (API โ Bearer token from personal_access_tokens table)
tokenGuard := auth.NewTokenGuard(db)
// โโ Dashboard: accepts both browser sessions AND API tokens โโ
app.Router.Get("/dashboard", dashboardHandler).
Use(middleware.AnyGuard("", sessionGuard, tokenGuard))
// โโ Web-only routes (always redirect to /login) โโโโโโโโโโโโโโ
web := app.Router.Group("/")
web.Use(auth.RequireAuth(sessionGuard, "/login"))
web.Get("/settings", settingsHandler)
// โโ API-only routes (return 401 JSON) โโโโโโโโโโโโโโโโโโโโโโโโ
api := app.Router.Group("/api")
api.Use(auth.RequireToken(tokenGuard))
api.Get("/me", meHandler)
}
Reading the user inside a handler
After any auth middleware runs, retrieve the user the same way regardless of which guard authenticated it:
func dashboardHandler(c *http.Context) error {
user := auth.UserFromContext(c.Request.Context())
// user is guaranteed non-nil here โ middleware already returned 401 if not set
appUser := user.(*models.User)
return c.JSON(http.StatusOK, map[string]string{
"message": "Welcome " + appUser.Name,
})
}
How token vs session detection works
| Client type | Request | Guard that wins |
|---|---|---|
| Browser | Cookie session_id | SessionGuard |
| Mobile / SPA | Authorization: Bearer <token> | TokenGuard |
| Neither | No credentials | 401 Unauthorized |
Checking which guard was used
If you need to know which guard authenticated the request (e.g. to branch on web vs API behaviour), store a guard name on context:
type guardNameKey struct{}
// GuardNameFromContext returns "web", "api", or "" if not set.
func GuardNameFromContext(ctx context.Context) string {
name, _ := ctx.Value(guardNameKey{}).(string)
return name
}
func AnyGuardNamed(redirectTo string, guards map[string]auth.Guard) router.Middleware {
return func(next router.HandlerFunc) router.HandlerFunc {
return func(c *http.Context) error {
for name, g := range guards {
user, err := g.User(c.Request.Context())
if err != nil {
return err
}
if user != nil {
ctx := auth.WithUser(c.Request.Context(), user)
ctx = context.WithValue(ctx, guardNameKey{}, name)
c.Request = c.Request.WithContext(ctx)
return next(c)
}
}
if redirectTo != "" {
c.Redirect(http.StatusFound, redirectTo)
return nil
}
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
}
}
}
// In handler:
func dashboardHandler(c *http.Context) error {
guard := middleware.GuardNameFromContext(c.Request.Context()) // "web" or "api"
_ = guard
user := auth.UserFromContext(c.Request.Context())
return c.JSON(200, map[string]string{"guard": guard, "user": user.GetID()})
}
Stateless (JWT) guard
If you use auth.NewStatelessGuard (JWT) instead of personal access tokens, swap it into AnyGuard as a drop-in replacement โ the interface is identical:
jwtGuard := auth.NewStatelessGuard(auth.StatelessConfig{
Secret: os.Getenv("JWT_SECRET"),
Loader: userLoader,
})
app.Router.Get("/dashboard", dashboardHandler).
Use(middleware.AnyGuard("", sessionGuard, jwtGuard))
See also
- Session Guard โ cookie-based auth for web routes
- Access Tokens โ personal access tokens for API clients
- Stateless Guard โ JWT-based stateless auth
- Authorization โ Gates and policies