“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:
- User logs in successfully
- Server generates a random session token
- Server stores user info associated with that token
- Server sends the token as a cookie
- Client includes the token in subsequent requests
- 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.