← Back to list

Go Authentication from Scratch — Password Hashing and Session Management Done Right

How to store passwords securely with bcrypt and manage sessions in Go. The foundation most developers skip.

Feildrix · 2026-05-19 00:01 · 0 claps · 8.1 min read paywalled
#golang #golang-tutorial #authentication #backend-development #software-development
Open on Medium ↗
Wiki topics: BIZ · Business Strategy 🌐 · Web Development

Go Authentication from Scratch — Password Hashing and Session Management Done Right

How to store passwords securely with bcrypt and manage sessions in Go. The foundation most developers skip.

Picture this: you just deployed your first Go application. The API runs smoothly, the database is connected, every feature works. Then you realize — you’re storing user passwords as plain text in the database.

That scenario is more common than you’d think. And more dangerous than it looks.

One database breach, and every user’s password is immediately readable. Worse, since many people reuse passwords across multiple services, a single breach in your app can cascade far beyond it.

In Part 1 of this series, we’ll build the right foundation for authentication: securely storing passwords using bcrypt, and managing session cookies to maintain login state across requests. No magic auth libraries — we’ll understand every line of code.

Hashing vs. Encryption — Get This Right First

Before writing a single line of code, it’s worth clarifying a concept that trips up a lot of developers.

Encryption is a two-way process — data is encrypted and can be decrypted back to its original form. If you store passwords using encryption, there’s a key that can unlock them. If that key leaks, it’s game over.

Hashing is a one-way process — the input is transformed into a fixed-length string (the hash) that is mathematically irreversible. When a user logs in, you don’t “decrypt” their password — you hash what they submitted and compare the result.

"password123" → bcrypt → "$2a$12$N9qo8uLOickgx2ZMRZoMye..."

Two separate runs produce different hashes (because of the random salt), yet CompareHashAndPassword can still verify both. That's what makes bcrypt secure.

Why bcrypt and not SHA-256 or MD5?

A fair question. MD5 and SHA-256 are hash functions too, but they were designed for speed — great for file checksums, terrible for passwords. A modern GPU can compute billions of SHA-256 hashes per second.

bcrypt, by contrast, is intentionally slow. A benchmark on AWS EC2 using hashcat makes the difference starkly clear:

MD5:     380,000,000 hashes/sec
SHA-256: 110,000,000 hashes/sec
bcrypt:      25,000 hashes/sec  ← this is exactly what we want

That several-thousand-fold difference is what makes brute-force attacks impractical.

Project Setup

mkdir go-auth-series && cd go-auth-series
go mod init github.com/yourname/go-auth-series

# Install dependencies
go get golang.org/x/crypto/bcrypt
go get github.com/gorilla/sessions

File structure we’ll build:

go-auth-series/
├── main.go
├── auth/
│   ├── password.go   ← bcrypt hashing
│   └── session.go    ← session management
└── go.mod

Part A: Password Hashing with bcrypt

auth/password.go

package auth

import (
    "errors"
    "golang.org/x/crypto/bcrypt"
)

// Cost is the bcrypt work factor.
// 12 is a solid sweet spot between security and performance on modern hardware (2025).
// Every increment of 1 doubles the computation time.
const Cost = 12

// HashPassword takes a plain-text password and returns a bcrypt hash.
// Salt is generated automatically by the library - never generate it yourself.
func HashPassword(password string) (string, error) {
    if len(password) == 0 {
        return "", errors.New("password cannot be empty")
    }
    // bcrypt only processes the first 72 bytes of input
    if len([]byte(password)) > 72 {
        return "", errors.New("password too long (max 72 bytes)")
    }
    hash, err := bcrypt.GenerateFromPassword([]byte(password), Cost)
    if err != nil {
        return "", err
    }
    return string(hash), nil
}

// CheckPassword verifies a plain-text password against a stored hash.
// Always use this function - never compare the strings manually.
func CheckPassword(password, hash string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    return err == nil
}

Two things worth highlighting here:

Work factor 12: This is the recommended value for modern hardware in 2025. The bcrypt.DefaultCost is only 10, which is starting to show its age. OWASP recommends a minimum of 10, but 12 gives you a better security buffer. The higher the value, the longer hashing takes — benchmark on your own server and pick the highest value that still responds within ~300ms.

The 72-byte limit: This is a bcrypt algorithm constraint that many developers don’t know about. The Go library ([golang.org/x/crypto/bcrypt](https://pkg.go.dev/golang.org/x/crypto/bcrypt)) will now return ErrPasswordTooLong explicitly in newer versions, but validating upfront gives your users a cleaner error message instead of a cryptic library error.

Testing the password functions

// auth/password_test.go
package auth

import "testing"

func TestHashAndCheck(t *testing.T) {
    password := "Str0ng&SecurePass!"
    hash, err := HashPassword(password)
    if err != nil {
        t.Fatalf("HashPassword failed: %v", err)
    }
    // The hash must differ from the plain-text password
    if hash == password {
        t.Error("hash should not equal the original password")
    }
    // Verification should succeed with the correct password
    if !CheckPassword(password, hash) {
        t.Error("CheckPassword returned false for a correct password")
    }
    // Verification should fail with a wrong password
    if CheckPassword("wrongpassword", hash) {
        t.Error("CheckPassword returned true for an incorrect password")
    }
}

func TestSamePasswordProducesDifferentHashes(t *testing.T) {
    password := "SamePasswordEveryTime"
    hash1, _ := HashPassword(password)
    hash2, _ := HashPassword(password)
    // Two hashes from the same password must be different (random salt)
    // but both must still verify correctly
    if hash1 == hash2 {
        t.Error("two hashes of the same password should differ")
    }
    if !CheckPassword(password, hash1) || !CheckPassword(password, hash2) {
        t.Error("both hashes should verify successfully")
    }
}

Part B: Session Management with Cookies

JWT is popular, but for traditional web applications that render HTML server-side (Go templates, for example), session cookies are a simpler and more mature approach.

Here’s how it works:

  1. User logs in → server verifies credentials
  2. Server creates a session and stores data server-side
  3. Client receives a cookie containing an encrypted session ID
  4. On every subsequent request, the cookie is sent → server looks up the session → user is identified

auth/session.go

package auth

import (
    "net/http"
    "os"
    "github.com/gorilla/sessions"
)

const sessionName = "go-auth-session"

// store is the global session store.
// The key must be 32 bytes for AES-256 encryption.
// Never hardcode this in production - load it from an environment variable.
var store *sessions.CookieStore

func init() {
    key := os.Getenv("SESSION_KEY")
    if key == "" {
        // Fallback for local development only - NEVER use this in production
        key = "dev-only-key-32-bytes-change-me!"
    }
    store = sessions.NewCookieStore([]byte(key))
    store.Options = &sessions.Options{
        Path:     "/",
        MaxAge:   86400 * 7,               // 7 days in seconds
        HttpOnly: true,                    // inaccessible to JavaScript (anti-XSS)
        Secure:   true,                    // only sent over HTTPS
        SameSite: http.SameSiteStrictMode, // anti-CSRF
    }
}

// SetUserSession stores user information in the session after a successful login.
func SetUserSession(w http.ResponseWriter, r *http.Request, userID, email string) error {
    session, err := store.Get(r, sessionName)
    if err != nil {
        return err
    }
    session.Values["authenticated"] = true
    session.Values["user_id"] = userID
    session.Values["email"] = email
    return session.Save(r, w)
}

// GetUserSession retrieves user data from the active session.
// Returns userID, email, and whether the user is authenticated.
func GetUserSession(r *http.Request) (userID, email string, authenticated bool) {
    session, err := store.Get(r, sessionName)
    if err != nil {
        return "", "", false
    }
    auth, ok := session.Values["authenticated"].(bool)
    if !ok || !auth {
        return "", "", false
    }
    userID, _ = session.Values["user_id"].(string)
    email, _ = session.Values["email"].(string)
    return userID, email, true
}

// DestroySession invalidates the session (used on logout).
func DestroySession(w http.ResponseWriter, r *http.Request) error {
    session, err := store.Get(r, sessionName)
    if err != nil {
        return err
    }
    // MaxAge -1 tells the browser to delete the cookie immediately
    session.Options.MaxAge = -1
    return session.Save(r, w)
}

The three cookie options that matter most for security:

Option Value Why HttpOnly true JavaScript cannot read the cookie — prevents XSS from stealing sessions Secure true Cookie is only sent over HTTPS — prevents network sniffing SameSite Strict Cookie is not sent from other domains — prevents CSRF

Wiring It All Together in main.go

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "time"
    "github.com/yourname/go-auth-series/auth"
)

// User simulates a database record
type User struct {
    ID           string
    Email        string
    PasswordHash string
}

// In-memory user store - replace with a real database in production
var users = map[string]*User{} // key: email

func registerHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    var req struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request body", http.StatusBadRequest)
        return
    }
    if _, exists := users[req.Email]; exists {
        http.Error(w, "email already registered", http.StatusConflict)
        return
    }
    hash, err := auth.HashPassword(req.Password)
    if err != nil {
        http.Error(w, "invalid password: "+err.Error(), http.StatusBadRequest)
        return
    }
    userID := fmt.Sprintf("user_%d", time.Now().UnixNano())
    users[req.Email] = &User{
        ID:           userID,
        Email:        req.Email,
        PasswordHash: hash,
    }
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(map[string]string{
        "message": "registration successful",
        "user_id": userID,
    })
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    var req struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request body", http.StatusBadRequest)
        return
    }
    user, exists := users[req.Email]
    // IMPORTANT: The error message must be identical whether the email
    // doesn't exist or the password is wrong. Different messages allow
    // attackers to enumerate valid usernames (user enumeration attack).
    if !exists || !auth.CheckPassword(req.Password, user.PasswordHash) {
        http.Error(w, "invalid email or password", http.StatusUnauthorized)
        return
    }
    if err := auth.SetUserSession(w, r, user.ID, user.Email); err != nil {
        http.Error(w, "failed to create session", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(map[string]string{
        "message": "login successful",
    })
}

func profileHandler(w http.ResponseWriter, r *http.Request) {
    userID, email, authenticated := auth.GetUserSession(r)
    if !authenticated {
        http.Error(w, "unauthorized - please log in", http.StatusUnauthorized)
        return
    }
    json.NewEncoder(w).Encode(map[string]string{
        "user_id": userID,
        "email":   email,
    })
}

func logoutHandler(w http.ResponseWriter, r *http.Request) {
    if err := auth.DestroySession(w, r); err != nil {
        http.Error(w, "failed to logout", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(map[string]string{
        "message": "logged out successfully",
    })
}

func main() {
    http.HandleFunc("/register", registerHandler)
    http.HandleFunc("/login", loginHandler)
    http.HandleFunc("/profile", profileHandler) // protected route
    http.HandleFunc("/logout", logoutHandler)
    log.Println("Server running on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Try it with curl

# 1. Register a new user
curl -X POST http://localhost:8080/register \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"Str0ng&SecurePass!"}'
# → {"message":"registration successful","user_id":"user_..."}

# 2. Login and save the session cookie
curl -X POST http://localhost:8080/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"Str0ng&SecurePass!"}' \
  -c cookies.txt
# → {"message":"login successful"}

# 3. Access the protected route with the cookie
curl http://localhost:8080/profile -b cookies.txt
# → {"email":"user@example.com","user_id":"user_..."}

# 4. Without a cookie - rejected
curl http://localhost:8080/profile
# → unauthorized - please log in

# 5. Logout
curl http://localhost:8080/logout -b cookies.txt -c cookies.txt
# → {"message":"logged out successfully"}

Common Mistakes to Avoid

1. Leaking which field was wrong during login

// ❌ WRONG — enables user enumeration attacks
if !exists {
    http.Error(w, "email not found", http.StatusUnauthorized)
    return
}

if !auth.CheckPassword(req.Password, user.PasswordHash) {
    http.Error(w, "incorrect password", http.StatusUnauthorized)
    return
}

// ✅ RIGHT - one generic message for both cases
if !exists || !auth.CheckPassword(req.Password, user.PasswordHash) {
    http.Error(w, "invalid email or password", http.StatusUnauthorized)
    return
}

2. Hardcoding the session key in source code

// ❌ WRONG — never commit secrets to git
store = sessions.NewCookieStore([]byte("my-secret-key"))

// ✅ RIGHT - load from environment
store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))

3. Forgetting to set MaxAge = -1 on logout

Simply deleting the authenticated value from the session doesn't remove the cookie from the browser. A technically savvy user can still reuse the old cookie. Always invalidate by setting MaxAge = -1.

4. Using too low a work factor

bcrypt.DefaultCost (value 10) is showing its age in 2025. Use at least 12, or higher if your server can handle it — and benchmark to find the right balance for your infrastructure.

Key Takeaways

  • Always use bcrypt (or argon2id) for password storage — never MD5, SHA-1, or raw SHA-256.
  • A work factor of 12 is the recommended baseline for 2025; measure on your server and adjust up if possible.
  • Session cookies with HttpOnly + Secure + SameSite=Strict are already protected against the most common XSS and CSRF attacks.
  • Login error messages must be generic — never reveal whether it was the email or password that was wrong.
  • Store your session key in an environment variable, never hardcoded in source.
  • On logout, set MaxAge = -1 so the cookie is properly removed from the browser.

What’s Next?

In Part 2, we’ll shift to an approach better suited for APIs — JSON Web Tokens (JWT). We’ll build an access token and refresh token system from scratch using [golang-jwt/jwt](https://github.com/golang-jwt/jwt), understand the claims structure, and handle edge cases like expired and tampered tokens.

If you’re building an application consumed by a mobile app or an SPA (React, Vue, etc.), Part 2 is essential reading.

This article is part of the Go Auth Series:


메타데이터
post_id
bc693ffc10b1
slug
go-authentication-from-scratch-password-hashing-and-session-management-done-right-bc693ffc10b1
url
https://medium.com/@feildrixliemdra/go-authentication-from-scratch-password-hashing-and-session-management-done-right-bc693ffc10b1
canonical_url
https://medium.com/@feildrixliemdra/go-authentication-from-scratch-password-hashing-and-session-management-done-right-bc693ffc10b1
author_url
https://medium.com/@feildrixliemdra
status
ok
fetched_at
2026-06-09 15:37:30