Skip to main content

Authentication and Authorization in Go Web Apps

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

Authentication answers “who is this user?” — verification by password, token, or third-party. Authorization answers “what are they allowed to do?” — enforcement by role, permission, or policy. Both are middleware concerns in Go web applications: they run before your handlers and either pass through or reject the request.

This guide covers the practical patterns: JWT for stateless APIs, bcrypt for passwords, sessions for traditional web apps, OAuth2 for social login, and RBAC middleware.

For HTTP middleware fundamentals see Go HTTP client and server. For TLS configuration see Go HTTPS and TLS.

Password Hashing with bcrypt

Never store passwords as plaintext or with weak hashes (MD5, SHA1). golang.org/x/crypto/bcrypt implements the bcrypt adaptive hash function — the cost factor makes it intentionally slow, defeating brute-force attacks:

import "golang.org/x/crypto/bcrypt"

const bcryptCost = 12  // 12 is a good production default (adjust based on server speed)

func hashPassword(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
    if err != nil {
        return "", fmt.Errorf("hashing password: %w", err)
    }
    return string(bytes), nil
}

func checkPassword(hashedPassword, plainPassword string) error {
    return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
    // returns nil on match, bcrypt.ErrMismatchedHashAndPassword on mismatch
}

// Usage in login handler
func handleLogin(w http.ResponseWriter, r *http.Request) {
    email    := r.PostFormValue("email")
    password := r.PostFormValue("password")

    user, err := db.GetUserByEmail(r.Context(), email)
    if err != nil || checkPassword(user.PasswordHash, password) != nil {
        // Same response for "user not found" and "wrong password" — prevents enumeration
        http.Error(w, "invalid credentials", http.StatusUnauthorized)
        return
    }
    // issue token or session...
}

Cost 12 means 2^12 = 4096 rounds. At cost 12, hashing takes ~250ms on modern hardware — acceptable for login, but too slow for high-frequency API calls. That’s intentional: it makes brute-force attacks ~4096x harder than a fast hash.

JWT Authentication

JWT (JSON Web Token) is a signed token that encodes claims (user ID, roles, expiration). The server doesn’t store session state — it verifies the signature on every request.

go get github.com/golang-jwt/jwt/v5
import "github.com/golang-jwt/jwt/v5"

type Claims struct {
    UserID string   `json:"user_id"`
    Email  string   `json:"email"`
    Roles  []string `json:"roles"`
    jwt.RegisteredClaims
}

var jwtSecret = []byte(os.Getenv("JWT_SECRET"))  // load from environment, not hardcoded

func issueToken(userID, email string, roles []string) (string, error) {
    claims := &Claims{
        UserID: userID,
        Email:  email,
        Roles:  roles,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Subject:   userID,
        },
    }
    return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret)
}

func parseToken(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (any, error) {
        // Verify the signing method — prevent algorithm confusion attacks
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
        }
        return jwtSecret, nil
    })
    if err != nil {
        return nil, err
    }
    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }
    return claims, nil
}

Always validate the signing method — without the check, an attacker could change the algorithm to none and forge tokens.

JWT Middleware

The middleware extracts the token from the Authorization header, validates it, and stores the claims in the request context:

type contextKey struct{ name string }
var claimsKey = contextKey{"claims"}

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        header := r.Header.Get("Authorization")
        if !strings.HasPrefix(header, "Bearer ") {
            http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
            return
        }

        claims, err := parseToken(strings.TrimPrefix(header, "Bearer "))
        if err != nil {
            http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
            return
        }

        ctx := context.WithValue(r.Context(), claimsKey, claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Helper for handlers to retrieve claims
func claimsFromContext(ctx context.Context) (*Claims, bool) {
    c, ok := ctx.Value(claimsKey).(*Claims)
    return c, ok
}

Role-Based Access Control (RBAC)

RBAC middleware reads the user’s roles from the JWT claims and rejects requests that don’t have the required role:

func requireRole(roles ...string) func(http.Handler) http.Handler {
    roleSet := make(map[string]bool, len(roles))
    for _, r := range roles {
        roleSet[r] = true
    }

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            claims, ok := claimsFromContext(r.Context())
            if !ok {
                http.Error(w, `{"error":"unauthenticated"}`, http.StatusUnauthorized)
                return
            }
            for _, role := range claims.Roles {
                if roleSet[role] {
                    next.ServeHTTP(w, r)
                    return
                }
            }
            http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
        })
    }
}

// Route setup
mux.Handle("GET /api/users",        authMiddleware(http.HandlerFunc(listUsers)))
mux.Handle("DELETE /api/users/{id}", authMiddleware(requireRole("admin")(http.HandlerFunc(deleteUser))))

For fine-grained permissions beyond roles, use a permission map: user.Permissions["articles:delete"].

Session-Based Authentication

Sessions are appropriate for traditional web applications (HTML forms, cookie-based auth). The session ID is stored in a cookie; the server looks up session data on each request:

import "github.com/gorilla/sessions"

var store = sessions.NewCookieStore(
    []byte(os.Getenv("SESSION_AUTH_KEY")),  // authentication key (32 or 64 bytes)
    []byte(os.Getenv("SESSION_ENC_KEY")),   // encryption key (16, 24, or 32 bytes)
)

func init() {
    store.Options = &sessions.Options{
        Path:     "/",
        MaxAge:   86400 * 7, // 7 days
        HttpOnly: true,       // not accessible via JavaScript — prevents XSS token theft
        Secure:   true,       // HTTPS only
        SameSite: http.SameSiteLaxMode,
    }
}

func handleLogin(w http.ResponseWriter, r *http.Request) {
    // ... verify credentials ...

    session, _ := store.Get(r, "session")
    session.Values["user_id"] = user.ID
    session.Values["email"] = user.Email
    session.Save(r, w)

    http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}

func handleLogout(w http.ResponseWriter, r *http.Request) {
    session, _ := store.Get(r, "session")
    session.Options.MaxAge = -1  // expire immediately
    session.Save(r, w)
    http.Redirect(w, r, "/login", http.StatusSeeOther)
}

func sessionMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        session, err := store.Get(r, "session")
        if err != nil || session.IsNew {
            http.Redirect(w, r, "/login", http.StatusSeeOther)
            return
        }
        if _, ok := session.Values["user_id"]; !ok {
            http.Redirect(w, r, "/login", http.StatusSeeOther)
            return
        }
        next.ServeHTTP(w, r)
    })
}

OAuth2: Google Login

OAuth2 lets users authenticate with a third-party provider. The server exchanges an authorization code for tokens and uses those to fetch the user’s profile:

import "golang.org/x/oauth2/google"

var googleOAuth = &oauth2.Config{
    ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
    ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
    RedirectURL:  "https://yourapp.com/auth/google/callback",
    Scopes:       []string{"https://www.googleapis.com/auth/userinfo.email"},
    Endpoint:     google.Endpoint,
}

func handleGoogleLogin(w http.ResponseWriter, r *http.Request) {
    state := generateState()  // random string, store in session to verify later
    session, _ := store.Get(r, "session")
    session.Values["oauth_state"] = state
    session.Save(r, w)
    http.Redirect(w, r, googleOAuth.AuthCodeURL(state), http.StatusTemporaryRedirect)
}

func handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
    session, _ := store.Get(r, "session")
    if r.FormValue("state") != session.Values["oauth_state"] {
        http.Error(w, "invalid state", http.StatusBadRequest)
        return
    }

    token, err := googleOAuth.Exchange(r.Context(), r.FormValue("code"))
    if err != nil {
        http.Error(w, "failed to exchange token", http.StatusInternalServerError)
        return
    }

    // Fetch user profile
    client := googleOAuth.Client(r.Context(), token)
    resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
    if err != nil {
        http.Error(w, "failed to fetch profile", http.StatusInternalServerError)
        return
    }
    defer resp.Body.Close()

    var profile struct {
        ID    string `json:"id"`
        Email string `json:"email"`
        Name  string `json:"name"`
    }
    json.NewDecoder(resp.Body).Decode(&profile)

    // Upsert user in database, create session
    user, _ := db.UpsertGoogleUser(r.Context(), profile.ID, profile.Email, profile.Name)
    session.Values["user_id"] = user.ID
    session.Save(r, w)
    http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}

CSRF Protection

Session-based apps are vulnerable to CSRF (cross-site request forgery) — a malicious site can submit forms to your server using the victim’s cookies. Protect POST/PUT/DELETE endpoints with a CSRF token:

import "github.com/gorilla/csrf"

// Wrap your router with CSRF protection
protected := csrf.Protect(
    []byte(os.Getenv("CSRF_KEY")),
    csrf.Secure(true),
)(mux)

// In HTML forms, include the CSRF token
func formHandler(w http.ResponseWriter, r *http.Request) {
    templates.ExecuteTemplate(w, "form.html", map[string]any{
        csrf.TemplateTag: csrf.TemplateField(r),  // {{.csrfField}} in template
    })
}

JWT-based APIs don’t need CSRF protection because they don’t use cookies for authentication — the token must be explicitly included in the Authorization header, which cross-site requests can’t do.

Summary

  • Hash passwords with bcrypt at cost 10–14; use CompareHashAndPassword for verification — return the same error for “user not found” and “wrong password”
  • Validate JWT signing method before returning the key — prevents algorithm confusion attacks
  • Store JWT claims in request context via middleware; handlers retrieve them with a typed context key
  • HttpOnly: true on session cookies prevents JavaScript access — mitigates XSS token theft
  • Always validate the OAuth2 state parameter in the callback — prevents CSRF on the OAuth flow
  • CSRF tokens are required for session-based apps; JWT-based APIs don’t need them

Resources

Comments

👍 Was this article helpful?