← Back to the section

When people say "add authorization," they often mean just one thing. In reality these are three different questions, asked in three different places:

  1. Who is this? — validating the JWT at the entrance.
  2. Do they have the right to access this endpoint at all? — the role check.
  3. Do they have the right to work with this specific resource? — the ownership check.

If you mix them into one place, you get either duplication (with a risk of divergence) or an omission — and the endpoint turns into a hole.

Three layers, three responsibilities

In a Go application with chi, each of the three questions lives in its own layer:

LayerWhat it checksTool
Gateway / API edgethe JWT signature, expiration, issuerAuthN middleware
BFF / Application Layerwhether the user has the role for this endpointRequireRoles on a chi group
Domain Handlerwhether the resource belongs to this userAccessPolicy in core/

Gateway — who is this user

The first layer answers only one question: "is the token genuine?". The AuthN middleware sits at the very beginning of the chain on the whole router. It checks the JWT signature, expiration, and other technical parameters. On success it puts the *Principal struct into context.Context so that the following layers can read it.

// adapters/in/http/middleware/authn.go

func AuthN(v *security.JWTValidator) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
            if raw == "" {
                httperr.Write(w, r, &apperr.AuthError{Reason: "missing token"})
                return
            }
            principal, err := v.Validate(raw)
            if err != nil {
                httperr.Write(w, r, err) // invalid JWT → 401
                return
            }
            ctx := context.WithValue(r.Context(), principalKey, principal)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

Important: the Gateway doesn't know which roles are needed for a specific endpoint, and knows nothing about the domain model. Its job is only to establish the identity.

BFF — can this user access the endpoint

The second layer checks the role. For example, POST /orders can be done only by a customer, and POST /products only by an admin. This is RBAC (Role-Based Access Control) — control by role, without knowledge of the specific resource.

RequireRoles is hung on a chi group. If the role doesn't fit — 403 Forbidden is returned, and the request doesn't reach the Handler.

// adapters/in/http/router.go

r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator)) // first, on the whole router

r.Group(func(r chi.Router) {
    r.Use(middleware.RequireRoles("customer"))
    r.Post("/orders", h.CreateOrder)
    r.Get("/orders/{id}", h.GetOrder)
})

r.Group(func(r chi.Router) {
    r.Use(middleware.RequireRoles("admin"))
    r.Post("/orders/{id}/cancel", h.AdminCancelOrder)
    r.Post("/products", h.CreateProduct)
})

The RequireRoles implementation reads *Principal from the context (which AuthN put there) and checks for a role match:

// adapters/in/http/middleware/rbac.go

func RequireRoles(roles ...string) func(http.Handler) http.Handler {
    allowed := make(map[string]struct{}, len(roles))
    for _, r := range roles {
        allowed[r] = struct{}{}
    }
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            p := PrincipalFrom(r.Context())
            if p == nil {
                httperr.Write(w, r, &apperr.AuthError{Reason: "unauthenticated"})
                return
            }
            for _, role := range p.Roles {
                if _, ok := allowed[role]; ok {
                    next.ServeHTTP(w, r)
                    return
                }
            }
            httperr.Write(w, r, &apperr.ForbiddenError{Required: roles})
        })
    }
}

Note: RequireRoles doesn't work without AuthN before it. If AuthN didn't put *Principal into the context, PrincipalFrom will return nil, and the middleware will return 401.

Domain Handler — does the resource belong to this user

The third layer is the most subtle. The user has already passed the first two: the token is valid, the role fits. But a customer shouldn't see someone else's order. This is ABAC (Attribute-Based Access Control) — a check by the attributes of the specific resource.

AccessPolicy lives in core/<aggregate>/ — right where the aggregate itself is. That's exactly why it knows how to determine the "owner":

// core/order/access.go

type OrderAccessPolicy struct{}

func (p *OrderAccessPolicy) CheckOwnership(order *Order, principal *security.Principal) error {
    for _, role := range principal.Roles {
        if role == "admin" {
            return nil // admin sees everything
        }
    }
    if order.CustomerID != principal.Sub {
        return &apperr.ForbiddenError{
            Resource:   "order",
            ResourceID: order.ID,
        }
    }
    return nil
}

The Handler calls the policy after loading the aggregate from the repository:

// core/order/handler/get_order.go

func (h *GetOrderHandler) Handle(ctx context.Context, cmd GetOrderCommand) (OrderView, error) {
    order, err := h.repo.ByID(ctx, cmd.OrderID)
    if err != nil {
        return OrderView{}, fmt.Errorf("load order %s: %w", cmd.OrderID, err)
    }
    principal := security.PrincipalFrom(ctx)
    if err := h.policy.CheckOwnership(order, principal); err != nil {
        return OrderView{}, err
    }
    return toView(order), nil
}

Scenario: we loaded order.ID=SB-12345, it has CustomerID="user-42", and the token has sub="user-99" — rejection with ForbiddenError. The user's role is correct, the endpoint is accessible, but the specific resource isn't theirs.

Why ABAC can't be done at the Gateway

This is a common mistake: an attempt to check resource ownership right at the entrance, in the middleware.

The problem is that the Gateway doesn't know the domain model. To answer the question "whose order is this?", it would have to go to the database or call another service. Effectively — duplicate the domain. On any change to the model (for example, an order gets several owners) the Gateway has to be updated in parallel — that's a double source of truth.

The rule is simple: ABAC lives where the aggregate lives, because only it knows its own ownership rules.

Common mistakes

RBAC at the Gateway instead of the route group. The Gateway doesn't know which endpoint is for which role. The role check belongs to the BFF layer — in RequireRoles on a chi group.

ABAC in the controller instead of AccessPolicy. A if p.Roles[0] == "admin" check straight in the controller is procedural code without isolation. If the logic changes, you'll have to change it in every place. AccessPolicy as a separate type in core/ is a single place for all ownership rules.

JWT validation in the Handler or UseCase. If jwt.Parse is called in several places, then on a change of algorithm or keys you'll have to change everything. The AuthN middleware is one place for the whole router.

Only RBAC without ABAC for own-resource endpoints. The customer role allows access to GET /orders/{id}, but doesn't mean the order belongs to this user. RBAC on the group + ABAC in the Handler — both layers are mandatory.

Confusing AuthError (401) and ForbiddenError (403). An invalid token — 401 ("who are you?"). A valid token but no access — 403 ("I know you, but I won't let you in"). Returning the wrong code breaks clients and masks the real cause.

In short

  • Auth is three different checks: who (JWT), which role (RBAC), whose resource (ABAC).
  • The AuthN middleware sits first on the whole router and puts *Principal into the context.
  • RequireRoles is hung on a chi group and checks the role before entering the Handler.
  • AccessPolicy lives in core/<aggregate>/ and checks ownership of the specific aggregate.
  • RequireRoles doesn't work without AuthN before it — PrincipalFrom will return nil.
  • ABAC at the Gateway is impossible without duplicating the domain model — don't do it.
  • The errors are different: an invalid token → 401, no access → 403.