Guards: Authentication and Route Authorization in Go

A guard is a small piece of logic that decides whether a request is allowed to reach your handler. In NestGo, guards are the Go equivalent of NestJS guards — the standard place for authentication checks and route authorization in a Golang API.

Guards run before the handler (and before interceptors). If a guard denies the request, the handler never executes.

The Guard interface

A guard implements a single method:

// Guard decides whether a request is allowed to proceed.
// Return true to allow, false to deny.
// Returning an error short-circuits with that error.
type Guard interface {
    CanActivate(c Context) (bool, error)
}

The return values control what happens next:

Return Result
true, nil Request proceeds to the next guard (or the handler)
false, nil Request is rejected with 403 Forbidden ("access denied")
_, err Chain short-circuits with err — use this to send a specific status like 401

Return an error (for example core.ErrUnauthorized(...)) when you want to control the status code and message; return false, nil when the generic 403 is fine.

GuardFunc: write a guard as a function

For most guards you don’t need a struct — GuardFunc adapts a plain function to the Guard interface:

authGuard := core.GuardFunc(func(c core.Context) (bool, error) {
    token := c.GetHeader("Authorization")
    if token == "" {
        return false, core.ErrUnauthorized("missing token")
    }
    return true, nil
})

A struct-based guard is useful when the guard has dependencies (a token verifier, a user service):

type JWTGuard struct {
    verifier TokenVerifier
}

func (g *JWTGuard) CanActivate(c core.Context) (bool, error) {
    token := c.GetHeader("Authorization")
    claims, err := g.verifier.Verify(token)
    if err != nil {
        return false, core.ErrUnauthorized("invalid token")
    }
    c.Set("user", claims) // downstream guards/handlers can read this
    return true, nil
}

UseGuards: turn guards into middleware

UseGuards converts one or more guards into a MiddlewareFunc so they plug into any route, group, or the whole server:

func UseGuards(guards ...Guard) MiddlewareFunc

Guards run in the order given — the first failure stops the chain.

mw := core.UseGuards(authGuard, adminGuard) // auth first, then role check

How to apply guards at global, controller, and route level

NestGo supports the same three levels as NestJS:

// 1. Global — every route in the app:
config.GlobalGuards = []core.Guard{authGuard}

// 2. Controller / group level — every route in the group:
admin := r.Group("/admin", core.UseGuards(adminGuard))

// 3. Route level — one specific route:
r.DELETE("/:id", handler, core.UseGuards(ownerGuard))
Level How Typical use
Global config.GlobalGuards = []core.Guard{...} Authentication for the whole API
Controller/group r.Group(prefix, core.UseGuards(...)) Admin-only sections
Route r.DELETE("/:id", handler, core.UseGuards(...)) Ownership or per-action checks

In the full execution chain, guards sit between exception filters and pipes: Filters → Guards → Pipes → Interceptors → Handler. See Exception Filters for the complete picture, and RouteOptions for bundling all of them in the correct order.

Role-based authorization with route metadata

NestJS uses @SetMetadata('roles', ...) plus a Reflector; NestGo uses core.WithMeta, which stores a value in the request context so guards can read it with c.Get(key).

// A role guard that reads the route's "roles" metadata:
roleGuard := core.GuardFunc(func(c core.Context) (bool, error) {
    meta := c.Get("roles")
    if meta == nil {
        return true, nil // route declares no role requirement
    }
    allowed, ok := meta.([]string)
    if !ok {
        return false, nil
    }

    userRole, _ := c.Get("role").(string) // set earlier by the auth guard
    for _, r := range allowed {
        if r == userRole {
            return true, nil
        }
    }
    return false, core.ErrForbidden("insufficient role")
})

// Attach metadata + guard to a route.
// WithMeta must come BEFORE UseGuards so the value is set when the guard runs:
r.DELETE("/:id", handler,
    core.WithMeta("roles", []string{"admin"}),
    core.UseGuards(roleGuard),
)

To attach several metadata keys at once, use core.Meta:

r.GET("/reports", handler,
    core.Meta(map[string]interface{}{
        "roles":      []string{"admin", "auditor"},
        "permission": "reports:read",
    }),
    core.UseGuards(roleGuard),
)

Complete example: auth + roles on an admin group

package main

import "github.com/ashrafAli23/nestgo/core"

func RegisterAdminRoutes(r core.Router) {
    authGuard := core.GuardFunc(func(c core.Context) (bool, error) {
        token := c.GetHeader("Authorization")
        if token == "" {
            return false, core.ErrUnauthorized("missing token")
        }
        // ...verify token, then store the user's role:
        c.Set("role", "admin")
        return true, nil
    })

    roleGuard := core.GuardFunc(func(c core.Context) (bool, error) {
        allowed, _ := c.Get("roles").([]string)
        userRole, _ := c.Get("role").(string)
        for _, r := range allowed {
            if r == userRole {
                return true, nil
            }
        }
        return false, nil // -> 403 "access denied"
    })

    admin := r.Group("/admin", core.UseGuards(authGuard))

    admin.DELETE("/users/:id", deleteUserHandler,
        core.WithMeta("roles", []string{"admin"}),
        core.UseGuards(roleGuard),
    )
}

Requests without a token get 401, authenticated users without the admin role get 403, and admins reach the handler.

Guards vs middleware in Go

Plain middleware can do everything a guard does — so why guards? Guards give authorization a name and a contract: CanActivate returns an explicit allow/deny, denial handling is centralized (403 by default), and RouteOptions slots guards into the correct position in the chain automatically. Use middleware for transport concerns (CORS, compression, logging) and guards for “may this request proceed?”.

Source: github.com/ashrafAli23/nestgo (core/guard.go, core/meta.go).

Next steps

  • Interceptors — run logic before and after the handler
  • Pipes — transform and validate extracted request values
  • Exception Filters — customize error responses and see the full execution order