Are you building a Go application that needs robust user access control? Role-Based Access Control (RBAC) is the gold standard for securing modern applications, and implementing it correctly in Golang can make the difference between a vulnerable system and an enterprise-grade solution.
In this comprehensive guide, I'll walk you through building a production-ready RBAC system in Go, complete with real-world examples, best practices, and the latest security standards.
>> Read more: 10 Critical Golang Security Vulnerabilities You Must Know
Why RBAC Matters for Go Developers?
Before diving into code, let's understand why RBAC has become essential for modern applications:
- Scalability: Manage permissions for thousands of users efficiently
- Security: Implement the principle of least privilege automatically
- Compliance: Meet regulatory requirements for access control
- Maintainability: Centralized permission management reduces complexity
Understanding RBAC Core Components
RBAC operates on four fundamental building blocks that every Go developer should master:
Users and Roles
Users represent real people or system accounts, while roles act like job titles that define what those users are allowed to do.
type User struct {
ID string `json:"id" db:"id"`
Username string `json:"username" db:"username"`
Email string `json:"email" db:"email"`
Roles []string `json:"roles" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type Role struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Permissions []string `json:"permissions" db:"-"`
Hierarchy int `json:"hierarchy" db:"hierarchy"`
}
Resources and Actions
Every action in an app—like creating a post or deleting a comment—is tied to a resource. RBAC ensures only the right roles can perform those actions.
type Resource struct {
Path string `json:"path"`
Type ResourceType `json:"type"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type Action string
const (
Create Action = "create"
Read Action = "read"
Update Action = "update"
Delete Action = "delete"
List Action = "list"
)
Choosing the Right RBAC Library for Go
Instead of reinventing the wheel, most Go developers rely on well-tested libraries. After extensive testing, here are the top libraries:
Casbin (Recommended)
Why it's the best choice:
- Supports multiple access control models (RBAC, ABAC, ACL)
- Production-tested with 13k+ GitHub stars
- Excellent performance with caching support
- Active community and regular updates
Latest features in v2.122.0:
import "github.com/casbin/casbin/v2"
// Initialize with enhanced configuration
func NewRBACEnforcer() (*casbin.Enforcer, error) {
e, err := casbin.NewEnforcer("model.conf", "policy.csv")
if err != nil {
return nil, fmt.Errorf("failed to create enforcer: %w", err)
}
// Enable concurrent protection (new in v2.122.0)
e.EnableAutoSave(true)
e.EnableLog(true)
return e, nil
}
Auth0 SDK
Best for enterprise applications:
- Managed authentication service
- Comprehensive role management UI
- Multi-tenant support out of the box
Custom Implementation
When to build your own:
- Unique business requirements
- Maximum performance needs
- Full control over authorization logic
Building Your First Golang RBAC System
Let's build a complete RBAC system from scratch. Here's our project structure:
rbac-blog-example/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── auth/
│ │ ├── middleware.go
│ │ ├── jwt.go
│ │ └── rbac.go
│ ├── models/
│ │ └── user.go
│ └── handlers/
│ └── api.go
├── configs/
│ ├── model.conf
│ └── policy.csv
└── go.mod
Step 1: Define Your RBAC Model
Create configs/model.conf:
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act
Step 2: Set Up Your Policies
Create configs/policy.csv:
p, admin, /api/*, *
p, editor, /api/posts/*, GET
p, editor, /api/posts/*, POST
p, editor, /api/posts/*, PUT
p, user, /api/posts/*, GET
g, alice, admin
g, bob, editor
g, charlie, user
Step 3: Implement JWT Authentication
// internal/auth/jwt.go
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type RBACClaims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Roles []string `json:"roles"`
Permissions []string `json:"permissions"`
jwt.RegisteredClaims
}
func GenerateToken(userID, username string, roles []string, secret []byte) (string, error) {
claims := RBACClaims{
UserID: userID,
Username: username,
Roles: roles,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "rbac-blog-example",
Subject: userID,
},
}
// Resolve permissions for roles
permissions, err := resolveRolePermissions(roles)
if err != nil {
return "", fmt.Errorf("failed to resolve permissions: %w", err)
}
claims.Permissions = permissions
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
func ValidateToken(tokenString string, secret []byte) (*RBACClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &RBACClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return secret, nil
})
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
if claims, ok := token.Claims.(*RBACClaims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token claims")
}
>> Read more: A Complete Guide to Implement Golang gRPC with Example
Step 4: Create Authorization Middleware
// internal/auth/middleware.go
package auth
import (
"context"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/casbin/casbin/v2"
"github.com/gin-gonic/gin"
)
type AuthMiddleware struct {
enforcer *casbin.Enforcer
secret []byte
logger *slog.Logger
}
func NewAuthMiddleware(enforcer *casbin.Enforcer, secret []byte, logger *slog.Logger) *AuthMiddleware {
return &AuthMiddleware{
enforcer: enforcer,
secret: secret,
logger: logger,
}
}
func (am *AuthMiddleware) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
startTime := time.Now()
// Extract token
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
am.respondWithError(c, http.StatusUnauthorized, "missing authorization header")
return
}
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
am.respondWithError(c, http.StatusUnauthorized, "invalid authorization format")
return
}
// Validate token
claims, err := ValidateToken(tokenParts[1], am.secret)
if err != nil {
am.logger.Warn("token validation failed",
slog.String("error", err.Error()),
slog.String("ip", c.ClientIP()))
am.respondWithError(c, http.StatusUnauthorized, "invalid token")
return
}
// Check authorization
resource := c.Request.URL.Path
action := strings.ToUpper(c.Request.Method)
authorized, err := am.checkPermission(c.Request.Context(), claims.UserID, resource, action)
if err != nil {
am.logger.Error("authorization check failed",
slog.String("user_id", claims.UserID),
slog.String("resource", resource),
slog.String("action", action),
slog.String("error", err.Error()))
am.respondWithError(c, http.StatusInternalServerError, "authorization check failed")
return
}
if !authorized {
am.logger.Warn("access denied",
slog.String("user_id", claims.UserID),
slog.String("resource", resource),
slog.String("action", action),
slog.Any("roles", claims.Roles))
am.respondWithError(c, http.StatusForbidden, "access denied")
return
}
// Store auth context
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("roles", claims.Roles)
am.logger.Info("request authorized",
slog.String("user_id", claims.UserID),
slog.String("resource", resource),
slog.String("action", action),
slog.Duration("auth_duration", time.Since(startTime)))
c.Next()
}
}
func (am *AuthMiddleware) checkPermission(ctx context.Context, userID, resource, action string) (bool, error) {
// Use context with timeout for authorization check
ctxWithTimeout, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resultChan := make(chan authResult, 1)
go func() {
allowed, err := am.enforcer.Enforce(userID, resource, action)
resultChan <- authResult{allowed: allowed, err: err}
}()
select {
case result := <-resultChan:
return result.allowed, result.err
case <-ctxWithTimeout.Done():
return false, fmt.Errorf("authorization timeout: %w", ctxWithTimeout.Err())
}
}
type authResult struct {
allowed bool
err error
}
func (am *AuthMiddleware) respondWithError(c *gin.Context, statusCode int, message string) {
c.JSON(statusCode, gin.H{
"error": message,
"timestamp": time.Now().UTC(),
"path": c.Request.URL.Path,
})
c.Abort()
}
Step 5: Implement API Handlers
// internal/handlers/api.go
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
)
type PostHandler struct {
// Add your dependencies here
}
func NewPostHandler() *PostHandler {
return &PostHandler{}
}
func (h *PostHandler) GetPosts(c *gin.Context) {
userID := c.GetString("user_id")
username := c.GetString("username")
// Your business logic here
posts := []gin.H{
{"id": 1, "title": "Getting Started with RBAC", "author": "admin"},
{"id": 2, "title": "Advanced Go Patterns", "author": "editor"},
}
c.JSON(http.StatusOK, gin.H{
"posts": posts,
"user_id": userID,
"username": username,
"message": "Posts retrieved successfully",
})
}
func (h *PostHandler) CreatePost(c *gin.Context) {
userID := c.GetString("user_id")
var post struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&post); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Your business logic here
newPost := gin.H{
"id": 3,
"title": post.Title,
"content": post.Content,
"author": userID,
}
c.JSON(http.StatusCreated, gin.H{
"post": newPost,
"message": "Post created successfully",
})
}
func (h *PostHandler) UpdatePost(c *gin.Context) {
postID := c.Param("id")
userID := c.GetString("user_id")
var post struct {
Title string `json:"title"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&post); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Your business logic here
updatedPost := gin.H{
"id": postID,
"title": post.Title,
"content": post.Content,
"author": userID,
}
c.JSON(http.StatusOK, gin.H{
"post": updatedPost,
"message": "Post updated successfully",
})
}
func (h *PostHandler) DeletePost(c *gin.Context) {
postID := c.Param("id")
userID := c.GetString("user_id")
// Your business logic here
c.JSON(http.StatusOK, gin.H{
"post_id": postID,
"user_id": userID,
"message": "Post deleted successfully",
})
}
>> Read more:
- API Development in Go with OpenAPI
- Gin-Gonic Tutorial: API Development in Go Using Gin Framework
Step 6: Wire Everything Together
// cmd/server/main.go
package main
import (
"log"
"log/slog"
"os"
"github.com/casbin/casbin/v2"
"github.com/gin-gonic/gin"
"your-module/internal/auth"
"your-module/internal/handlers"
)
func main() {
// Initialize logger
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Initialize Casbin enforcer
enforcer, err := casbin.NewEnforcer("configs/model.conf", "configs/policy.csv")
if err != nil {
log.Fatal("Failed to create enforcer:", err)
}
// JWT secret (use environment variable in production)
jwtSecret := []byte("your-super-secret-key")
// Initialize middleware
authMiddleware := auth.NewAuthMiddleware(enforcer, jwtSecret, logger)
// Initialize handlers
postHandler := handlers.NewPostHandler()
// Setup Gin router
router := gin.Default()
// Public routes
router.POST("/auth/login", func(c *gin.Context) {
// Implement your login logic here
// For demo purposes, we'll create a token for alice (admin)
token, err := auth.GenerateToken("alice", "alice", []string{"admin"}, jwtSecret)
if err != nil {
c.JSON(500, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(200, gin.H{
"token": token,
"user": "alice",
"roles": []string{"admin"},
})
})
// Protected routes
api := router.Group("/api")
api.Use(authMiddleware.RequireAuth())
{
posts := api.Group("/posts")
{
posts.GET("", postHandler.GetPosts)
posts.POST("", postHandler.CreatePost)
posts.PUT("/:id", postHandler.UpdatePost)
posts.DELETE("/:id", postHandler.DeletePost)
}
}
// Health check
router.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "healthy"})
})
logger.Info("Starting server on :8080")
if err := router.Run(":8080"); err != nil {
log.Fatal("Failed to start server:", err)
}
}
Testing Your RBAC Implementation
Here's how to test your implementation:
Get an Authentication Token
curl -X POST <http://localhost:8080/auth/login> \\
-H "Content-Type: application/json"
Test Different Endpoints
# Test as admin (should work)
curl -X GET <http://localhost:8080/api/posts> \\
-H "Authorization: Bearer YOUR_TOKEN_HERE"
# Test creating a post
curl -X POST <http://localhost:8080/api/posts> \\
-H "Authorization: Bearer YOUR_TOKEN_HERE" \\
-H "Content-Type: application/json" \\
-d '{"title": "My First Post", "content": "Hello RBAC!"}'
Advanced RBAC Patterns
Dynamic Role Assignment
func (am *AuthMiddleware) AssignRole(userID, role string) error {
return am.enforcer.AddGroupingPolicy(userID, role)
}
func (am *AuthMiddleware) RevokeRole(userID, role string) error {
return am.enforcer.RemoveGroupingPolicy(userID, role)
}
Resource-Based Permissions
// Check if user owns the resource
func (am *AuthMiddleware) CheckResourceOwnership(ctx context.Context, userID, resourceID string) (bool, error) {
// Your logic to check if user owns the resource
// This could involve database queries or other checks
return true, nil
}
Time-Based Access Control
type TimeBoundRole struct {
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
}
func (claims *RBACClaims) HasActiveRole(role string) bool {
// Check if role is still active based on time constraints
for _, r := range claims.Roles {
if r == role {
// Add your time-based logic here
return true
}
}
return false
}
Performance Optimization Tips
Implement Permission Caching
import (
"github.com/go-redis/redis/v8"
"encoding/json"
"time"
)
type PermissionCache struct {
client *redis.Client
ttl time.Duration
}
func (pc *PermissionCache) GetCachedPermission(key string) (bool, error) {
result, err := pc.client.Get(context.Background(), key).Result()
if err != nil {
return false, err
}
var allowed bool
if err := json.Unmarshal([]byte(result), &allowed); err != nil {
return false, err
}
return allowed, nil
}
Batch Permission Checks
func (am *AuthMiddleware) BatchEnforce(requests [][]interface{}) ([]bool, error) {
return am.enforcer.BatchEnforce(requests)
}
Security Best Practices
Implement Rate Limiting
import "golang.org/x/time/rate"
func RateLimitMiddleware() gin.HandlerFunc {
limiter := rate.NewLimiter(100, 10) // 100 requests per second, burst of 10
return func(c *gin.Context) {
if !limiter.Allow() {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "rate limit exceeded",
})
c.Abort()
return
}
c.Next()
}
}
Add Request Logging
func RequestLoggingMiddleware(logger *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
logger.Info("request completed",
slog.String("method", c.Request.Method),
slog.String("path", c.Request.URL.Path),
slog.Int("status", c.Writer.Status()),
slog.Duration("duration", time.Since(start)),
slog.String("ip", c.ClientIP()),
slog.String("user_agent", c.Request.UserAgent()))
}
}
Secure Token Storage
// Use secure HTTP-only cookies for token storage
func SetSecureCookie(c *gin.Context, token string) {
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(
"auth_token",
token,
3600, // 1 hour
"/",
"yourdomain.com",
true, // Secure
true, // HttpOnly
)
}
Common Pitfalls and How to Avoid Them
- Overly Complex Role Hierarchies
Problem: Creating too many granular roles
Solution: Start simple with basic roles (admin, editor, user) and evolve
- Forgetting to Validate Input
Problem: Not sanitizing resource paths
Solution: Always validate and sanitize inputs
func validateResourcePath(path string) error {
if strings.Contains(path, "..") {
return fmt.Errorf("invalid path: directory traversal detected")
}
return nil
}
- Not Handling Token Expiration
Problem: Users getting logged out abruptly
Solution: Implement token refresh logic
func RefreshToken(oldToken string, secret []byte) (string, error) {
claims, err := ValidateToken(oldToken, secret)
if err != nil {
return "", err
}
// Generate new token with extended expiration
return GenerateToken(claims.UserID, claims.Username, claims.Roles, secret)
}
Deployment Considerations
Environment Configuration
type Config struct {
JWTSecret string `env:"JWT_SECRET,required"`
TokenDuration time.Duration `env:"TOKEN_DURATION" envDefault:"24h"`
DatabaseURL string `env:"DATABASE_URL,required"`
RedisURL string `env:"REDIS_URL"`
}
Docker Configuration
>> Consider: A Comprehensive Guide To Dockerize A Golang Application
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main cmd/server/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/
COPY --from=builder /app/main .
COPY --from=builder /app/configs ./configs
EXPOSE 8080
CMD ["./main"]
Conclusion
Building a robust RBAC system in Go doesn't have to be overwhelming. By following the patterns and examples in this guide, you can create a secure, scalable access control system that grows with your application.
- golang
- coding
