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:
| Role | Who | What they do |
|---|---|---|
customer | End user | Creates and reads their own orders, pays |
seller | Seller | Manages their own products, sees the orders for their products |
admin | Internal operator | Full access, every action is written to the journal |
system | Another service | Inter-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 rolecustomer-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
systemrole 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) orscope(standard OAuth2);extractRoleshandles 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. RequireRolesis placed on achi.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.
What to read next
- JWT validation in Go — how
*Principalends up in the context before RBAC. - ABAC: resource ownership —
AccessPolicyandCheckOwnershipafter RBAC. - Which check goes where — where
AuthNlives, whereRequireRoles, where ABAC. - Inter-service calls — the
systemrole: the Client Credentials Flow and mTLS.