A Beautiful Solution to Auth Session Management in GO GIN
I come from a background of heavy JS and TS usage, coming from those languages I grew used to the awesome packages that some people have designed for me to use. The unfortunate fact though is that I don’t have access to the same NPM packages anymore.
One such package that I truly love is the Iron Session package. This baby allows you to store a session token with data. So on the server when I receive this token, I am able to store pieces of information like the user ID, what their favourite colour is, what some of their preferences are and so on. This sounds wildly insecure, but it’s not. You encrypt that data on the server into a string that can only be correctly decrypted and read if you have the password used to encrypt that data.
So you get this amazing session token that can store valuable bits of information in it whilst authenticating your users securely and save DB lookup time with the data held inside your session token.
Now GO, that I know of, doesn’t have this solution yet, and I thought it would be a great challenge for myself to design a version of this package myself. It will handle the encryption, decryption, validation and struct parsing that can be accessed and easily implemented into the application with middleware.
My favourite part of building applications is the auth. I could spend hours designing patterns and security features if I wanted, but of course, deadlines.
Let me just outline my general folder structure with this design:
src/
├── lib/
│ └── auth/
│ ├── clear-auth-cookie.go
│ ├── generate-token.go
│ ├── get-user-from-context.go
│ ├── manager.go
│ ├── middleware.go
│ ├── set-auth-cookie.go
│ └── verify-token.go
├── routers/
│ ├── public/
│ │ ├── login.go
│ │ └── logout.go
│ ├── private/
│ │ ├── protected.go # test route
│ │ └── me.go # test route
│ ├── public.go
│ └── private.go
└── main.go
Let’s take a look at the basic outline of the system I have built. We will start with the encryption/ token generation:
// src/lib/auth/generate-token.go
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"time"
)
const (
Prefix = "Fe_" // in tribute to Iron Session (Fe = Iron)
)
// GenerateToken creates a new auth token
func (m *Manager) GenerateToken(userID, username string) (string, error) {
token := TokenData{
UserID: userID,
Username: username,
IssuedAt: time.Now(),
ExpiresAt: time.Now().Add(m.config.TokenExpiration),
}
payload, err := json.Marshal(token)
if err != nil {
return "", fmt.Errorf("failed to marshal token: %w", err)
}
h := hmac.New(sha256.New, []byte(m.config.SecretKey))
h.Write(payload)
signature := h.Sum(nil)
tokenStr := fmt.Sprintf("%s.%s",
base64.URLEncoding.EncodeToString(payload),
base64.URLEncoding.EncodeToString(signature))
return Prefix + tokenStr, nil
}
The first step is simple, we create a struct with our user’s information and timing data. The IssuedAt
timestamp helps track when the token was created, while ExpiresAt
ensures the token has a limited lifetime.
token := TokenData{
UserID: userID,
Username: username,
IssuedAt: time.Now(),
ExpiresAt: time.Now().Add(m.config.TokenExpiration),
}
Next, we convert our token data into JSON. This makes our token portable and easy to decode later:
payload, err := json.Marshal(token)
The resulting JSON might look something like this:
{
"user_id": "123",
"username": "jane_doe",
"issued_at": "2024–11–08T10:00:00Z",
"expires_at": "2024–11–08T11:00:00Z"
}
Security is crucial, so we create an HMAC signature of our payload:
// m.config.SecretKey will make sense later on in this article
h := hmac.New(sha256.New, []byte(m.config.SecretKey))
h.Write(payload)
signature := h.Sum(nil)
This signature ensures that the token hasn’t been tampered with, was created by someone with access to the secret key, and the payload data remains intact.
Finally, we combine our payload and signature into a single string:
tokenStr := fmt.Sprintf("%s.%s",
base64.URLEncoding.EncodeToString(payload),
base64.URLEncoding.EncodeToString(signature))
We use base64 URL encoding to ensure our token is safe to use in URLs and headers. The final token format is:
Fe_<base64-encoded-payload>.<base64-encoded-signature>
Boom! So easy, you can now encrypt your data but, I mean, you can’t decrypt it or read it so it’s as useful as a bird without wings. Lets now build something to read the encrypted token. We will combine decryption with the validation as well.
// src/lib/auth/verify-token.go
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
// VerifyToken validates a token and returns its data
func (m *Manager) VerifyToken(tokenStr string) (*TokenData, error) {
if len(tokenStr) < 3 || tokenStr[:3] != Prefix {
return nil, fmt.Errorf("invalid token format")
}
tokenStr = tokenStr[3:]
parts := strings.Split(tokenStr, ".")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid token format")
}
payload, err := base64.URLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid payload encoding: %w", err)
}
h := hmac.New(sha256.New, []byte(m.config.SecretKey))
h.Write(payload)
expectedSig := h.Sum(nil)
actualSig, err := base64.URLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid signature encoding: %w", err)
}
if !hmac.Equal(expectedSig, actualSig) {
return nil, fmt.Errorf("invalid signature")
}
var token TokenData
if err := json.Unmarshal(payload, &token); err != nil {
return nil, fmt.Errorf("invalid token data: %w", err)
}
if time.Now().After(token.ExpiresAt) {
return nil, fmt.Errorf("token expired")
}
return &token, nil
}
Here we go, another piece of code you can copy and paste. So you don’t get confused, let me break it down for you…
Ok let me actually do it this time…
First, let’s look at the token format validation:
if len(tokenStr) < 3 || tokenStr[:3] != Prefix {
return nil, fmt.Errorf("invalid token format")
}
tokenStr = tokenStr[3:]
parts := strings.Split(tokenStr, ".")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid token format")
}
Each token should start with the prefix “Fe_” (as previously set in the other script) — this helps quickly identify our application’s tokens and reject obviously invalid ones. After removing this prefix, we split the token into its payload and signature components.
Next, we decode the payload:
payload, err := base64.URLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid payload encoding: %w", err)
}
The payload contains our token data in base64 encoded format. We decode it back into bytes, but we can’t trust its contents yet — we need to verify the signature first.
Let’s calculate the expected signature:
h := hmac.New(sha256.New, []byte(m.config.SecretKey))
h.Write(payload)
expectedSig := h.Sum(nil)
actualSig, err := base64.URLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid signature encoding: %w", err)
}
if !hmac.Equal(expectedSig, actualSig) {
return nil, fmt.Errorf("invalid signature")
}
We create an HMAC-SHA256 hash of the payload using our secret key, giving us the signature we expect to see. Then we decode the actual signature from the token and compare them using a secure, constant-time comparison. If they don’t match, the token has been tampered with.
Once we’ve verified the signature, we can parse the payload:
var token TokenData
if err := json.Unmarshal(payload, &token); err != nil {
return nil, fmt.Errorf("invalid token data: %w", err)
}
The payload is JSON-encoded data representing our TokenData struct. If it doesn’t match our expected structure, we know something’s wrong.
Finally, we check the expiration:
if time.Now().After(token.ExpiresAt) {
return nil, fmt.Errorf("token expired")
}
return &token, nil
Even a properly signed token needs to be checked for expiration. We compare the current time with the stored expiration time to ensure the token is still valid.
The error messages throughout the process are intentionally vague — we don’t want to give potential attackers too much information about why their invalid tokens were rejected. Each error case returns a simple, non-specific error message that helps with debugging while maintaining security.
I am trying to keep this short but there are a lot of moving parts and cool things. Truly one of my proudest pieces of code for now. On to the next block.
Where were we? ̶g̶e̶n̶e̶r̶a̶t̶e̶-̶t̶o̶k̶e̶n̶, ̶v̶e̶r̶i̶f̶y̶-̶t̶o̶k̶e̶n̶, ooh, the manager! This little puppy lets us manage the whole thing, go figure…
// src/lib/auth/manager.go
package auth
import "time"
// TokenData represents the contents of our auth token
type TokenData struct {
UserID string `json:"uid"`
Username string `json:"username"`
IssuedAt time.Time `json:"iat"`
ExpiresAt time.Time `json:"exp"`
}
// Config holds auth configuration
type Config struct {
SecretKey string
TokenExpiration time.Duration
CookieName string
CookieDomain string
CookieSecure bool
CookieHTTPOnly bool
}
// Manager handles authentication operations
type Manager struct {
config Config
}
var AuthManager *Manager
// NewManager creates a new auth manager
func NewManager(config Config) {
if config.TokenExpiration == 0 {
config.TokenExpiration = 24 * time.Hour // default 24 hours
}
if config.CookieName == "" {
config.CookieName = "<identifier>_auth"
}
AuthManager = &Manager{config: config}
return
}
If we take a quick look we are just setting up the main thing which is our AuthManager
. We have set this up in such a sense that we can access the AuthManager
operations throughout our application where we need. Just a simple little setup that initiates some config variables.
Wow this article is longer than I thought!
I’ll show you real quick where I put the manager in my main file:
// src/main.go
func main() {
startup()
app := gin.Default()
app.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "pong",
"time": time.Now(),
})
})
// Public routes
routers.PublicRouterGroup(app)
// Private routes
routers.PrivateRouterGroup(app)
err := app.Run()
if err != nil {
database.CleanUpDBConn()
log.Fatalf("error starting server: %v", err)
}
}
func startup() {
// Initialise .env
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
// initialise DB
err = database.Init()
if err != nil {
database.CleanUpDBConn()
log.Fatalf("couldn't initialise DB, check connection vars and db status: %v", err)
}
// ************** OVER HERE 👋 ***********************
auth.NewManager(auth.Config{
SecretKey: os.Getenv("<super cool app name>_API_SESSION_KEY_PASS"),
TokenExpiration: 24*time.Hour, // adjust as needed
CookieName: "<identifier>_auth",
CookieSecure: false, // Set to true in production
CookieHTTPOnly: true,
})
}
When I start an instance of my server it runs through and sets up some vital organs of the application, and guess what, that is our auth manager! Ok so that was really hard that last step… not, let’s look at how our middleware works.
// src/lib/auth/middleware.go
package auth
import (
"strings"
"github.com/gin-gonic/gin"
)
func (m *Manager) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := ""
// Check Authorization header first
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
}
// If no Authorization header, check cookie
if token == "" {
var err error
token, err = c.Cookie(m.config.CookieName)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "Unauthorized"})
return
}
}
// Verify token
tokenData, err := m.VerifyToken(token)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "Unauthorized"})
return
}
// Set user data in context
c.Set("user", tokenData)
c.Next()
}
}
Our middleware starts by defining a function that returns a Gin handler:
func (m *Manager) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := ""
// ... handler logic will go here
}
}
First, we check for a token in the Authorization header. This is the standard way for applications to send authentication tokens:
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
}
The Bearer scheme is a common standard — tokens are sent as “Bearer <token>”. We simply strip off the “Bearer “ prefix to get our token.
If we don’t find a token in the header, we fall back to checking cookies. This is particularly useful for web applications where JavaScript might not be handling the authentication:
if token == "" {
var err error
token, err = c.Cookie(m.config.CookieName)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "Unauthorized"})
return
}
}
Note how we immediately return with a 401 Unauthorized status if no token is found in either location.
Once we have a token, we need to verify it using our token verification system:
tokenData, err := m.VerifyToken(token)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "Unauthorized"})
return
}
This step ensures the token is valid, hasn’t been tampered with, and hasn’t expired. If any of these checks fail, we again, return a 401 status.
Finally, if everything checks out, we store the user data in the request context and continue the request pipeline:
c.Set("user", tokenData)
c.Next()
This makes the user information available to all subsequent handlers in the request chain. They can access it using the “user” key from the Gin context.
The beauty of this middleware lies in its flexibility. It supports multiple ways of sending tokens (headers or cookies), follows standard authentication patterns (Bearer scheme), and cleanly integrates with Gin’s context system. It’s just so bloody tasty dude!!
Now to glue some of this all together we can use this middleware within our application to protect route groups, or individual routes. I don’t care, it’s your application, do what you want.
// src/main.go - application entry point
func main() {
startup()
app := gin.Default()
app.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "pong",
"time": time.Now(),
})
})
// Public routes
routers.PublicRouterGroup(app)
// Private routes
routers.PrivateRouterGroup(app)
err := app.Run()
if err != nil {
database.CleanUpDBConn()
log.Fatalf("error starting server: %v", err)
}
}
So you can see in the code above I have some router groups, one public, and one private. The public is made for routes I need open without auth so that people can log in or out. The private will be routes within my API that I only want not so naughty people accessing. Let’s take a look:
// src/routers/private.go
package routers
import (
"github.com/myapp/src/lib/auth"
"github.com/myapp/src/routers/private"
"github.com/gin-gonic/gin"
)
func PrivateRouterGroup(r *gin.Engine) {
privateGroup := r.Group("/api")
// Setup auth middle ware on all routes in here
privateGroup.Use(auth.AuthManager.Middleware())
private.DevRouterProtected(privateGroup)
}
Oh, look at that, we used our middleware! Now we can do it like this because we setup the AuthManager
in our main file which setup this whole thing. Now if we want to use this anywhere we can just import it easily thanks to GO just being such a sexy language.
I am running out of brain power after writing this for a while now, but there is a basic outline of the whole authentication flow. I tell you what, I’ll put this code in a public repo for you and you can just copy and paste the code from there into your own application.
I specialize in optimizing logistics operations through custom software development, process automation, and digital transformation. View my complete portfolio at albarel.dev to see how I’ve helped companies streamline their operations and boost efficiency.
This post and code is 100% free-range. That means there could be errors, typos and I don’t know, edge cases? On a blog post? Knowing my luck… But what I am trying to say is I don’t go beep boop and I have to eat carbs to function and not electricity. So enjoy some of the last of the non AI articles on Medium. I promise to not use AI in my future blogs either. Pure 100% degeneracy and caffeine come down stories and posts.