Authentication answers "who is calling?" and authorization answers "are they allowed to do this?". In a Gin API, a clean way to handle both is Gin JWT and Casbin together: gin-jwt issues and verifies JSON Web Tokens at login, and Casbin decides, per route and method, whether the caller's role is allowed through. Both run as middleware, so handlers stay focused on business logic.
This is part two of a series. Part one built an articles API with Gin, GORM and PostgreSQL, and anyone can currently create or delete articles. By the end of this part, reads stay public, writes need a valid token, deletes need the admin role, and editors can only change their own articles.
How middleware works in Gin
A Gin middleware is just a gin.HandlerFunc that runs before (and optionally after) the route handler:
func RequestTimer() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next() // run the remaining middleware and the handler
log.Printf("%s %s -> %d in %s",
c.Request.Method, c.FullPath(), c.Writer.Status(), time.Since(start))
}
}The rules that matter:
- Middleware runs in the order it is registered.
r.Use()applies to every route,group.Use()to a group, and you can also pass middleware inline for one route:r.GET("/me", authMW, handler). c.Next()runs the rest of the chain. Code after it runs on the way back out.c.Abort()(orc.AbortWithStatusJSON) stops later handlers from running, but not the current function. Alwaysreturnafter aborting.c.Setandc.Getpass values, such as the current user, from middleware to handlers.
Adding users with a migration
Users need an email, a password hash and a role. Articles get an optional user_id so we know who wrote them.
package models
import (
"time"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Email string `gorm:"size:255;uniqueIndex;not null" json:"email"`
Name string `gorm:"size:100;not null" json:"name"`
Password string `gorm:"size:60;not null" json:"-"`
Role string `gorm:"size:20;not null;default:user" json:"role"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (u *User) SetPassword(plain string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil {
return err // includes bcrypt.ErrPasswordTooLong for > 72 bytes
}
u.Password = string(hash)
return nil
}
func (u *User) CheckPassword(plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(plain)) == nil
}Add a UserID *uint field (tagged gorm:"index" json:"userId") to models.Article. It is a pointer because the seeded articles have no author. Then append a migration to the gormigrate list:
{
ID: "202609250001_add_users",
Migrate: func(tx *gorm.DB) error {
type User struct {
ID uint `gorm:"primaryKey"`
Email string `gorm:"size:255;uniqueIndex;not null"`
Name string `gorm:"size:100;not null"`
Password string `gorm:"size:60;not null"`
Role string `gorm:"size:20;not null;default:user"`
CreatedAt time.Time
UpdatedAt time.Time
}
type Article struct {
UserID *uint `gorm:"index"`
User *User // creates the foreign key constraint
}
return tx.AutoMigrate(&User{}, &Article{})
},
Rollback: func(tx *gorm.DB) error {
if err := tx.Migrator().DropColumn("articles", "user_id"); err != nil {
return err
}
return tx.Migrator().DropTable("users")
},
},Hashing passwords with bcrypt
Never store passwords, even encrypted ones. Store a slow, salted hash. bcrypt generates the salt for you and embeds it, together with the cost, in the 60-character output, so one column is enough.
- Cost.
bcrypt.DefaultCostis 10, and each step up doubles the work, so cost 14 is 16 times slower. Measure on production hardware and pick the highest cost that keeps login fast enough. - Handle the error. Ignoring it can leave you storing an empty hash.
- 72-byte limit. bcrypt only uses the first 72 bytes, and
x/cryptonow returnsErrPasswordTooLonginstead of truncating silently. The validator'smax=72counts characters, not bytes, so Thai or emoji passwords can still exceed it. Handle the error explicitly.
The register handler:
type registerRequest struct {
Email string `json:"email" binding:"required,email,max=255"`
Name string `json:"name" binding:"required,max=100"`
Password string `json:"password" binding:"required,min=8,max=72"`
}
func (h *AuthHandler) Register(c *gin.Context) {
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Role is never taken from the request body.
user := models.User{Email: strings.ToLower(req.Email), Name: req.Name, Role: "user"}
if err := user.SetPassword(req.Password); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "password is too long"})
return
}
err := h.db.WithContext(c.Request.Context()).Create(&user).Error
switch {
case errors.Is(err, gorm.ErrDuplicatedKey):
c.JSON(http.StatusConflict, gin.H{"error": "email is already registered"})
case err != nil:
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create user"})
default:
c.JSON(http.StatusCreated, gin.H{"data": user})
}
}JWT login flow with gin-jwt
github.com/appleboy/gin-jwt/v2 provides a login handler, a refresh handler and a middleware that validates the Authorization: Bearer <token> header. You supply a few callbacks:
| Callback | Called when | Returns |
|---|---|---|
Authenticator | POST /login | The user, or an error for bad credentials |
PayloadFunc | Right after a successful login | Claims to put in the token |
IdentityHandler | Every protected request | The identity stored in the context |
Unauthorized | Any auth failure | Writes the error response |
package middleware
import (
"strings"
"time"
"example.com/articles-api/models"
jwt "github.com/appleboy/gin-jwt/v2"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const identityKey = "id"
type AuthUser struct {
ID uint
Role string
}
type loginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
func NewJWT(db *gorm.DB, secret []byte) (*jwt.GinJWTMiddleware, error) {
return jwt.New(&jwt.GinJWTMiddleware{
Realm: "articles-api",
Key: secret,
Timeout: 15 * time.Minute,
MaxRefresh: 24 * time.Hour,
IdentityKey: identityKey,
Authenticator: func(c *gin.Context) (interface{}, error) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
return nil, jwt.ErrMissingLoginValues
}
var user models.User
err := db.WithContext(c.Request.Context()).
Where("email = ?", strings.ToLower(req.Email)).First(&user).Error
// Same error for "no such user" and "wrong password".
if err != nil || !user.CheckPassword(req.Password) {
return nil, jwt.ErrFailedAuthentication
}
return &user, nil
},
PayloadFunc: func(data interface{}) jwt.MapClaims {
if u, ok := data.(*models.User); ok {
return jwt.MapClaims{identityKey: u.ID, "role": u.Role}
}
return jwt.MapClaims{}
},
IdentityHandler: func(c *gin.Context) interface{} {
claims := jwt.ExtractClaims(c)
id, _ := claims[identityKey].(float64) // JSON numbers decode as float64
role, _ := claims["role"].(string)
return &AuthUser{ID: uint(id), Role: role}
},
Unauthorized: func(c *gin.Context, code int, message string) {
c.JSON(code, gin.H{"error": message})
},
TokenLookup: "header: Authorization",
TokenHeadName: "Bearer",
})
}
// CurrentUser returns the identity set by the JWT middleware.
func CurrentUser(c *gin.Context) *AuthUser {
v, _ := c.Get(identityKey)
u, _ := v.(*AuthUser)
return u
}After the middleware validates a token, it calls IdentityHandler and stores the result under IdentityKey, so any handler can read the access payload with middleware.CurrentUser(c). Remember that a JWT is signed, not encrypted: anyone holding the token can base64-decode the claims. Put IDs and roles in it, never personal data or secrets.
Things to get right:
- Secret. Load it from an environment variable, require at least 32 random bytes, and refuse to start without it.
- Short access tokens.
Timeout: 15 * time.Minutelimits the damage from a leaked token.RefreshHandlerissues a new token while the original login is younger thanMaxRefresh. - Stale roles. The role is copied into the token at login, so a demotion takes effect only when the token expires. If that is unacceptable, look up the role from the database on each request instead.
- User enumeration. Return the same error for an unknown email and a wrong password. Timing can still leak which emails exist, because bcrypt only runs for real users; comparing against a dummy hash closes that gap.
Authorization with Casbin RBAC
Scattered if user.Role == "admin" checks don't scale. Casbin moves the rules into a model and a policy. The model defines how to match:
# config/rbac_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.actThe policy lists who may do what. g, admin, editor makes admin inherit every editor permission:
# config/rbac_policy.csv
p, editor, /api/v1/articles, POST
p, editor, /api/v1/articles/:id, PUT
p, admin, /api/v1/articles/:id, DELETE
g, admin, editorkeyMatch2 understands :id segments, so a policy can mirror the route definition. The middleware sends Casbin the role, the matched route and the method:
func Authorize(e *casbin.Enforcer) gin.HandlerFunc {
return func(c *gin.Context) {
user := CurrentUser(c)
if user == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
ok, err := e.Enforce(user.Role, c.FullPath(), c.Request.Method)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authorization failed"})
return
}
if !ok {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
c.Next()
}
}c.FullPath() returns the route template (/api/v1/articles/:id), not the raw URL, so encoded characters or extra slashes in the request can't slip past a policy. Note the status codes: 401 means "we don't know who you are", 403 means "we know, and the answer is no".
The file adapter is fine while policies change through code review. For runtime edits, store them in PostgreSQL with github.com/casbin/gorm-adapter/v3 and use casbin.NewSyncedEnforcer so reloads are safe under concurrent requests.
Protecting route groups
Wire everything together in routes.Register:
func Register(r *gin.Engine, db *gorm.DB, authMW *jwt.GinJWTMiddleware, enforcer *casbin.Enforcer) {
articles := controllers.NewArticleHandler(db)
auth := controllers.NewAuthHandler(db)
api := r.Group("/api/v1")
a := api.Group("/auth")
a.POST("/register", auth.Register)
a.POST("/login", authMW.LoginHandler)
a.GET("/refresh", authMW.RefreshHandler)
a.GET("/me", authMW.MiddlewareFunc(), auth.Me) // any logged-in user
// Public reads
api.GET("/articles", articles.List)
api.GET("/articles/:id", articles.Get)
// Writes: valid token first, then role check
protected := api.Group("")
protected.Use(authMW.MiddlewareFunc(), middleware.Authorize(enforcer))
protected.POST("/articles", articles.Create)
protected.PUT("/articles/:id", articles.Update)
protected.DELETE("/articles/:id", articles.Delete)
}And in main.go:
secret := []byte(os.Getenv("JWT_SECRET"))
if len(secret) < 32 {
log.Fatal("JWT_SECRET must be at least 32 bytes")
}
authMW, err := middleware.NewJWT(db, secret)
if err != nil {
log.Fatalf("jwt: %v", err)
}
enforcer, err := casbin.NewEnforcer("config/rbac_model.conf", "config/rbac_policy.csv")
if err != nil {
log.Fatalf("casbin: %v", err)
}
routes.Register(r, db, authMW, enforcer)Try it:
TOKEN=$(curl -s -X POST localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"correct-horse"}' | jq -r .token)
curl -X DELETE localhost:8080/api/v1/articles/1 -H "Authorization: Bearer $TOKEN"
# 403 for an editor, 204 for an adminOwnership checks: where RBAC stops
RBAC answers "can an editor update articles?", not "can this editor update this article?". That second question depends on data, so check it in the handler after loading the row:
me := middleware.CurrentUser(c)
if me.Role != "admin" && (article.UserID == nil || *article.UserID != me.ID) {
c.JSON(http.StatusForbidden, gin.H{"error": "you can only edit your own articles"})
return
}In Create, set article.UserID = &me.ID from the token, never from the request body. Casbin can model ownership too, with ABAC-style matchers, but a plain check next to the query is easier to read and test.
FAQ
Where should the frontend store the JWT?
For browser apps, an HttpOnly, Secure, SameSite cookie is harder to steal through XSS than localStorage. gin-jwt supports cookies with SendCookie: true and a TokenLookup that includes cookie: jwt. If you use cookies, protect state-changing requests against CSRF.
How do I log out with JWT?
A signed token stays valid until it expires. Keep tokens short, delete them on the client, and for instant revocation check a Redis denylist of token IDs in middleware.
What is the difference between Casbin RBAC and ABAC?
RBAC grants permissions to roles. ABAC decides from attributes of the user, resource and request, such as "owner equals caller". Casbin supports both through the matcher.
Why does my request get 401 instead of 403?
401 comes from the JWT middleware: the token is missing, malformed, expired or signed with a different secret. 403 comes from Casbin or an ownership check. Check the Authorization: Bearer header first.
Checklist
- Passwords are hashed with bcrypt, errors handled, cost measured.
- The JWT secret comes from the environment and is long and random.
- Access tokens are short-lived and contain no personal data.
- Roles are assigned server side, never from request bodies.
- Casbin policies mirror route templates via
c.FullPath(). - Ownership is checked in handlers after loading the row.
Next, part three tightens input validation, adds image uploads and paginates the article list. If you would like a second pair of eyes on an auth design before it ships, Vectorkub reviews and builds backend systems.
