← Back to the section

When an HTTP request arrives at the server, it has already passed authentication — the JWT token has been validated and is valid. But a signature check alone isn't enough: you need to make sure this user has the right to call this exact endpoint. RBAC is responsible for that — Role-Based Access Control, authorization based on roles.

In the Go stack with the chi router this is done with two functions: extractRoles reads the roles from the token, and RequireRoles checks them in middleware before the request reaches the handler.

How roles get from the token into the application

A JWT token carries roles in different places depending on the authorization server. Keycloak puts them into realm_access.roles:

{
  "sub": "customer-77",
  "realm_access": {
    "roles": ["customer"]
  }
}

Standard OAuth2 uses the scope field — a space-separated string: "scope": "customer read:orders".

The extractRoles function handles both formats and returns the roles as a slice of strings:

// adapters/in/http/security/extract.go

func extractRoles(claims jwt.MapClaims) []string {
    if ra, ok := claims["realm_access"].(map[string]any); ok {
        if roles, ok := ra["roles"].([]any); ok {
            return toStrings(roles)
        }
    }
    if scope, ok := claims["scope"].(string); ok {
        return strings.Fields(scope)
    }
    return nil
}

extractRoles is called inside JWTValidator.Validate — once, when the token is parsed. After that, nowhere in the code do you need to touch the raw claims: a ready *Principal with a Roles field sits in context.Context.

The role catalog

There should be few roles in the system. Practice shows that almost all tasks are covered by four standard roles:

RoleWhoWhat they do
customerEnd userCreates and reads their own orders, pays
sellerSellerManages their own products, sees the orders for their products
adminInternal operatorFull access, every action is written to the journal
systemAnother serviceInter-service calls via Client Credentials or mTLS

If you feel like adding customer-premium or partner-admin — it's most likely not a new role, but an attribute of an existing one. Examples:

  • "Customers with a subscription have other capabilities available" — that's the attribute Customer.HasSubscription, checked in the handler, not the role customer-premium.
  • "B2B customers don't see the retail catalog" — those are different parts of the system (different Bounded Contexts), not different roles in one service.
  • "An analyst can only view" — either an attribute on the system role with restricted access, or a separate role if it's a separate class of users.

A small role catalog is a deliberate constraint that simplifies the authorization system and simplifies auditing it.

RequireRoles: middleware on a group of routes

Roles are declared as constants so as not to scatter string literals throughout the code:

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

const (
    RoleCustomer = "customer"
    RoleSeller   = "seller"
    RoleAdmin    = "admin"
    RoleSystem   = "system"
)

The middleware itself receives a list of allowed roles and returns a standard http.Handler:

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})
        })
    }
}

If *Principal is absent from the context — that means the request didn't pass authentication, and 401 is returned. If a principal is present but none of the roles matched — 403. A single matching role from the list is enough.

How to set up the router

RequireRoles is placed on a chi.Group, not on each individual route. That way one middleware protects the whole group at once:

// adapters/in/http/router.go

r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator)) // puts *Principal into the context

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

r.Group(func(r chi.Router) {
    r.Use(middleware.RequireRoles(RoleSeller))
    r.Get("/products", h.ListProducts)
    r.Post("/products", h.CreateProduct)
    r.Put("/products/{id}", h.UpdateProduct)
})

r.Group(func(r chi.Router) {
    r.Use(middleware.RequireRoles(RoleAdmin))
    r.Post("/orders/{id}/refund", h.AdminRefundOrder)
    r.Post("/customers/{id}/block", h.AdminBlockCustomer)
})

AuthN is placed first on the whole router — by the time RequireRoles runs, *Principal is already in the context.

If an endpoint is needed by several roles — we pass several arguments:

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

RBAC and ABAC: two different questions

RBAC answers the question: "can the customer role call GET /orders/{id} at all?" That's a check at the endpoint level.

But there's another question: "can this specific customer read this specific order?" A role won't help here — you need to load the order and check that order.CustomerID matches principal.Sub. This is called ABAC (Attribute-Based Access Control) and lives in the handler, not in the middleware:

// 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
}

Without the RBAC check in the middleware, any customer can try to access someone else's order. Without the ABAC check in the handler — any customer reads someone else's orders just by guessing the ID. Both layers are needed.

Common mistakes

String comparison outside the middleware. Writing if p.Roles[0] == "admin" inside a handler is bad practice: the authorization logic spreads across the code and it's hard to find during an audit. A role is checked only through RequireRoles.

String literals instead of constants. RequireRoles("admin") in five places is five places where you can make a typo. The constants RoleAdmin, RoleCustomer, and so on solve this problem.

A group without RequireRoles. If a chi.Group has no RequireRoles, all incoming requests reach the handlers without a role check. If the endpoint really is public — that's a separate deliberate decision that should be explicitly noted in the code.

RequireRoles on each route instead of the group. It works, but it duplicates code and makes the router more complex. Grouping routes by role is the standard approach.

In short

  • A JWT token carries roles in realm_access.roles (Keycloak) or scope (standard OAuth2); extractRoles handles both formats.
  • After the token is parsed, the roles sit in Principal.Roles; the raw claims aren't used anywhere.
  • The standard catalog: customer, seller, admin, system. A new role is a signal to reconsider the domain model.
  • RequireRoles is placed on a chi.Group, not on each individual route.
  • RBAC is responsible for the right to call an endpoint; the "whose resource" check is done by ABAC in the handler.
  • Roles are declared as constants — no string literals scattered across the code.
  • JWT validation in Go — how *Principal ends up in the context before RBAC.
  • ABAC: resource ownership — AccessPolicy and CheckOwnership after RBAC.
  • Which check goes where — where AuthN lives, where RequireRoles, where ABAC.
  • Inter-service calls — the system role: the Client Credentials Flow and mTLS.