Rolling Your Own Authentication in Go

Go Web Development

“Don’t roll your own auth.”

The advice exists for good reason—authentication mistakes can be catastrophic. But the warning has morphed into something else: a belief that authentication is inherently complex and best left to third-party services.

It isn’t. Most auth libraries require adapting your entire application to their patterns. For standard username/password authentication with sessions, that’s overkill.

The core components are straightforward when broken down. Two resources were particularly useful:

This isn’t about reinventing cryptography. It’s about correctly implementing well-established patterns.

Core Components

An authentication system answers one question: “Are you who you claim to be?” Here’s what that requires:

  • Password Storage - Bcrypt hashing. Store the hash, never the password.
  • Secure Random Generation - Unpredictable tokens for sessions and CSRF.
  • Session Management - Remember authenticated users across requests.
  • Rate Limiting - Prevent brute force attacks.
  • CSRF Protection - Ensure requests originate from your application.
  • Auth Handlers - Registration and login endpoints.
  • Auth Middleware - Gate access to protected routes.

Each component has a single responsibility. No monolithic auth service to adapt your application around.

Password Storage

Never store passwords in plain text. Use bcrypt.

Unlike general-purpose hashing (SHA-256), bcrypt is intentionally slow. This makes brute-force attacks computationally expensive. The cost factor is adjustable—increase it as hardware gets faster.

package password
 
import (
	"golang.org/x/crypto/bcrypt"
)
 
func HashPassword(password string) (string, error) {
	passwordBytes := []byte(password)
	hashed, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.MinCost)
	if err != nil {
		return "", err
	}
	return string(hashed), nil
}
 
func DoPasswordsMatch(hashedPassword, test string) bool {
	if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(test)); err != nil {
		return false
	}
	return true
}

HashPassword creates the hash at registration. DoPasswordsMatch verifies it at login.

Secure Random Generation

Session tokens need to be unpredictable. Use crypto/rand, not math/rand. Standard random functions are predictable if you know the seed—cryptographically secure generators aren’t.

package random
 
import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base32"
	"encoding/hex"
)
 
func CreateRandomToken() string {
	bytes := make([]byte, 15)
	rand.Read(bytes)
	token := base32.StdEncoding.EncodeToString(bytes)
	return token
}
 
func EncodeToken(token string) string {
	byteId := sha256.Sum256([]byte(token))
	return hex.EncodeToString(byteId[:])
}

CreateRandomToken generates random bytes and base32-encodes them. EncodeToken hashes the token before storage—if someone gets your database, they can’t use the stored session IDs directly.

Session Management

HTTP is stateless. Sessions maintain state across requests by associating a token with user data on the server.

The flow:

  1. User logs in successfully
  2. Server generates a random session token
  3. Server stores user info associated with that token
  4. Server sends the token as a cookie
  5. Client includes the token in subsequent requests
  6. Server validates and retrieves the associated user
package session
 
import (
	"your/project/random"
)
 
func (s *service) CreateToken() string {
    // create a random token through our random package
	token := random.CreateRandomToken()
	return token
}
 
func (s *service) CreateSession(token string, userId uuid.UUID) error {
    // create a new session record from a random token and user id
	sessionId := hashToken(token)
	expiresAt := calcNextExpiry()
	session := Session{ExternalID: sessionId, ExpiresAt: expiresAt, UserID: userId}
	return s.repository.Save(&session)
}
 
func (s *service) ValidateSession(token string) (*Session, error) {
    // returns a session from a token, if there is any
    // and resets the expiry if successful
	sessionId := hashToken(token)
	session, err := s.repository.FindByExternalId(sessionId)
	if err != nil {
		return nil, err
	}
	if sessionIsExpired(session) {
		if err := s.repository.Delete(session.ID); err != nil {
			return nil, err
		}
		return nil, s.repository.db.ErrRecordNotFound
	}
	session.ExpiresAt = calcNextExpiry()
	if err := s.repository.Save(session); err != nil {
		return nil, err
	}
	return session, nil
}

Key details:

  • Hash the token before storing it. If your database leaks, attackers can’t use the session IDs directly.
  • Sessions expire. Reset expiry on each request so active users stay logged in.
  • Storage can be database, Redis, or in-memory (though in-memory won’t survive restarts).

Rate Limiting

Without rate limiting, attackers can try thousands of passwords per minute. Rate limiting caps attempts per user/IP within a timeframe.

This implementation uses a token bucket algorithm—each identifier gets a bucket that refills over time. Each attempt consumes from the bucket. Empty bucket means wait.

package ratelimit
 
import (
	"errors"
	"math"
	"sync"
	"time"
)
 
 
type Bucket struct {
	count      float64
	refilledAt int64
	mu         sync.Mutex
}
 
func (b *Bucket) consume(cost, maxValue float64, refillIntervalMillis int64) bool {
	// lock the bucket
	b.mu.Lock()
	defer b.mu.Unlock()
	// calculate refill until now & refill bucket
	now := time.Now().UTC().UnixMilli()
	refill := float64(now-b.refilledAt) / float64(refillIntervalMillis) * maxValue
	b.count = math.Min(b.count+refill, maxValue)
	b.refilledAt = now
 
	// deduct cost if possible
	if b.count >= cost {
		b.count -= cost
		return true
	}
	return false
}

Each email address and IP address gets its own bucket. Check capacity by attempting to deduct:

type TokenBucketRateLimit struct {
	refillIntervalMillis int64
	maxValue             float64
	buckets              map[string]*Bucket
}
 
func (tbr *TokenBucketRateLimit) Deduct(bucketLabel string, cost float64) error {
	now := time.Now().UTC().UnixMilli()
	// check if the label already has a bucket
	bucket, ok := tbr.buckets[bucketLabel]
	if !ok {
        // no bucket yet -> create one, then deduct again
		bucket := Bucket{count: tbr.maxValue, refilledAt: now}
		tbr.buckets[bucketLabel] = &bucket
		return tbr.Deduct(bucketLabel, cost)
	}
    // bucket exists -> consume
	if ok := bucket.consume(cost, tbr.maxValue, tbr.refillIntervalMillis); !ok {
		return errors.New("rate limit reached")
	}
	return nil
}

The bucket approach allows brief bursts (mistyped passwords) while blocking sustained attacks.

CSRF Protection

CSRF attacks trick authenticated users into submitting requests they didn’t intend. Your browser automatically includes session cookies, so a malicious site can submit forms to your app on behalf of logged-in users.

The common fix is CSRF tokens in forms. A simpler alternative: check the Origin header.

if request.Method != "GET" {
    originHeader := r.Header.Get("Origin")
    if originHeader != "https://yourapp.com" {
        w.WriteHeader(403)
        return
    }
}

This works when state-changing operations are restricted to POST/PUT/PATCH. For more complex scenarios, use proper CSRF tokens.

Registration and Login Handlers

These handlers bring all the components together.

package handler
 
// !! logging, error handling and validation
// left out for brevity and focus
 
type registerForm struct {
	Email           string
	Password        string
	ConfirmPassword string
}
 
// allow 5 requests per minute
var registerRateLimiter = ratelimit.New(60, 5)
 
func (h *Handler) HandleRegister(w http.ResponseWriter, r *http.Request) {
	userService := user.NewService(*user.NewRepository(h.DB))
	sessionService := session.NewService(*session.NewRepository(h.DB))
 
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Error parsing form", http.StatusBadRequest)
		return
	}
 
	req := registerForm{
		Email:           r.FormValue("email"),
		Password:        r.FormValue("password"),
		ConfirmPassword: r.FormValue("confirm"),
	}
 
    // check rate limit
	if err := registerRateLimiter.Deduct(req.Email, 1); err != nil {
		http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests)
		return
	}
	if err := registerRateLimiter.Deduct(req.OrgName, 1); err != nil {
		http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests)
		return
	}
 
    // check password guidelines
	if err := password.IsValidPassword(req.Password); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
 
    // check if passwords match
	if req.Password != req.ConfirmPassword {
		http.Error(w, "Passwords do not match", http.StatusBadRequest)
		return
	}
 
	// check if user already exists
	existing, err := userService.GetByEmail(req.Email)
	if err != nil && !errors.Is(err, h.DB.ErrRecordNotFound) {
		http.Error(w, "Error creating user", http.StatusBadRequest)
		return
	}
	if existing != nil {
		http.Error(w, "User already exists", http.StatusBadRequest)
		return
	}
 
	// create user
    pwHash := password.HashPassword(req.Password)
	user, err := userService.Create(req.Email, pwHash)
	if err != nil {
		http.Error(w, "Error creating user", http.StatusInternalServerError)
		return
	}
 
    // create session
	token := sessionService.CreateToken()
	if err := sessionService.Create(token, user.ID); err != nil {
		http.Error(w, "Error creating session", http.StatusInternalServerError)
		return
	}
 
	w.WriteHeader(http.StatusCreated)
}

Registration validates input, checks for existing users, hashes the password, creates the user, and starts a session.

Login handler:

package handler
 
// !! logging, error handling and validation
// left out for brevity and focus
 
type loginForm struct {
	Email    string
	Password string
}
 
var loginRateLimiter = ratelimit.New(60, 5)
 
func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
	userService := user.NewService(*user.NewRepository(h.DB))
	sessionService := session.NewService(*session.NewRepository(h.DB))
 
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Error parsing form", http.StatusBadRequest)
		return
	}
 
	req := loginForm{
		Email:    r.FormValue("email"),
		Password: r.FormValue("password"),
	}
 
	// Check rate limit
	if err := loginRateLimiter.Deduct(req.Email, 1); err != nil {
		http.Error(w, "Too many login attempts. Please try again later.", http.StatusTooManyRequests)
		return
	}
 
	// validate credentials
	user, err := userService.GetByEmail(req.Email)
	if err != nil {
		if errors.Is(err, h.DB.ErrRecordNotFound) {
			http.Error(w, "Wrong credentials", http.StatusUnauthorized)
			return
		}
		http.Error(w, "Error accessing user", http.StatusInternalServerError)
		return
	}
	if match := password.DoPasswordsMatch(user.Password, req.Password); !match {
		http.Error(w, "Wrong credentials", http.StatusUnauthorized)
		return
	}
 
	// create session
	token := sessionService.CreateToken()
	if err := sessionService.Create(token, user.ID); err != nil {
		http.Error(w, "Error creating session", http.StatusInternalServerError)
		return
	}
 
	// set session cookie
	http.SetCookie(w, &http.Cookie{
		Name:     session.AuthCookieName,
		Value:    token,
		HttpOnly: true,
		SameSite: http.SameSiteStrictMode,
	})
 
	w.WriteHeader(http.StatusOK)
	return
}

Note the error handling: don’t tell users whether the email exists or the password is wrong—just “wrong credentials.” This prevents username enumeration.

Auth Middleware

Middleware gates access to protected routes. Extract the session token, validate it, attach user info to the request context.

package mw
 
type contextKey string
 
const (
	SessionIdKey          contextKey = "sessionId"
	UserIDKey             contextKey = "userId"
)
 
func (h *Handler) Authenticate(next http.Handler) http.Handler {
	sessionService := session.NewService(*session.NewRepository(h.DB))
	userService := user.NewService(*user.NewRepository(h.DB))
 
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// get session cookie
		cookie, err := r.Cookie("<cookie-key>")
		if err != nil {
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
 
		authToken := cookie.Value
 
		session, err := sessionService.ValidateSession(authToken)
		if err != nil {
			if errors.Is(err, h.DB.ErrRecordNotFound) {
				http.Redirect(w, r, "/login", http.StatusSeeOther)
				return
			}
			http.Error(w, "Error validating session", http.StatusInternalServerError)
			return
		}
 
		user, err := userService.Get(session.UserID)
		if err != nil {
			http.Error(w, "Error getting user", http.StatusInternalServerError)
			return
		}
 
		ctx := r.Context()
		ctx = context.WithValue(ctx, SessionIdKey, session.ID.String())
		ctx = context.WithValue(ctx, UserIDKey, session.UserID.String())
 
		r = r.WithContext(ctx)
		next.ServeHTTP(w, r)
	})
}
 

Apply it to any route:

r.With(mwHandler.Authenticate).Route("/dashboard", func(r chi.Router) {
    r.Get("/", handler.HandleDashboard)
})

Authentication tells you who the user is. Authorization (checking roles/permissions) tells you what they can do—a separate concern.

When to Roll Your Own

Roll your own when:

  • Standard username/password with sessions is enough
  • You want full control and minimal dependencies
  • You have time to implement it correctly

Use an existing solution when:

  • You need MFA, social logins, or enterprise SSO
  • You’re on a tight deadline
  • Security expertise on the team is limited

The Tradeoffs

Rolling your own means owning security updates and edge cases (password resets, account recovery, session invalidation across devices). Third-party auth means adapting to their patterns and depending on their uptime.

Neither choice is universally right. For standard auth requirements, building it yourself is simpler than the conventional wisdom suggests. The complexity isn’t in the implementation—it’s in not skipping steps.

Imprint

This website is created and run by

Daniel Benner

Zur Deutschen Einheit 2

81929 München

Germany

hello(a)danielbenner.de