In this article
April 16, 2026
April 16, 2026

Building authentication in Go applications: The complete guide for 2026

Master secure authentication in Go, from middleware design and JWTs to session management and enterprise SSO, with production-ready patterns and security best practices.

Authentication in Go is a deliberate exercise. The language gives you a capable standard library, a strong type system, and almost no opinions about how to wire things together. There is no built-in user model, no auth middleware that ships with the runtime, and no framework that makes the decision for you. You build it yourself, or you choose a provider that handles it.

This guide covers everything you need to know about authentication in Go in 2026: from core concepts and security patterns to implementation strategies and production best practices. Whether you are building a REST API with the standard library, using a router like Chi or Echo, or evaluating managed solutions, you will gain the knowledge to make informed decisions for your application.

How authentication works in Go

Go does not ship with an authentication system. It gives you net/http and leaves the architecture to you.

This is done on purpose. Go's standard library is remarkably capable for building HTTP servers, and the language's emphasis on explicit code means your authentication logic is always visible and auditable.

The request lifecycle

Understanding how Go processes HTTP requests is foundational to implementing authentication correctly:

  1. Request arrives. The net/http server accepts the connection. Each request is handled in its own goroutine, so your server is concurrent by default.
  2. Middleware executes. If you have wrapped your handlers with middleware (logging, CORS, authentication), each wrapper runs in order. Middleware in Go is just a function that takes an http.Handler and returns an http.Handler.
  3. Authentication check. Your auth middleware inspects the request, whether that means verifying a JWT from the Authorization header, looking up a session ID from a cookie, or validating an API key. If the check fails, the middleware writes a 401 response and does not call the next handler.
  4. Handler executes. Your route handler processes the authenticated request. The authenticated user is typically stored in the request context via context.WithValue.
  5. Response is sent. Your handler writes the response. If you are using sessions, the session store is updated.

Go has no implicit middleware chain. Nothing runs unless you explicitly wire it. If you forget to wrap a route with your auth middleware, that route is open to anyone. This explicitness is one of Go's strengths for security-critical code: you can read the code and see exactly what protections are in place.

  
package main

import (
    "net/http"
)

func main() {
    mux := http.NewServeMux()

    // This route is completely open
    mux.HandleFunc("GET /public", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("anyone can see this"))
    })

    // This route is protected by auth middleware
    mux.Handle("GET /dashboard", requireAuth(http.HandlerFunc(dashboardHandler)))

    http.ListenAndServe(":8080", mux)
}
  

The middleware pattern

Go's approach to middleware is one of the most important patterns to understand for authentication. Unlike frameworks that use decorators, annotations, or configuration files, Go uses function composition. A middleware is any function that accepts an http.Handler and returns a new http.Handler:

  
func requireAuth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := extractBearerToken(r)
        if token == "" {
            http.Error(w, "missing token", http.StatusUnauthorized)
            return
        }

        claims, err := validateToken(token)
        if err != nil {
            http.Error(w, "invalid token", http.StatusUnauthorized)
            return
        }

        // Store the authenticated user in the request context
        ctx := context.WithValue(r.Context(), userContextKey, claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
  

This pattern composes naturally. You can stack middleware by wrapping handlers:

  
// Each layer wraps the next
handler := requireAuth(rateLimiter(loggingMiddleware(http.HandlerFunc(myHandler))))
  

Or, if you use a router like Chi, you can apply middleware to groups of routes:

  
r := chi.NewRouter()
r.Use(middleware.Logger)

r.Group(func(r chi.Router) {
    r.Use(requireAuth)
    r.Get("/dashboard", dashboardHandler)
    r.Get("/settings", settingsHandler)
})

r.Get("/public", publicHandler) // no auth middleware
  

The important thing to remember is that the request context (context.Context) is how you pass authenticated user information through the middleware chain to your handlers. This is idiomatic Go: the context flows with the request and carries deadlines, cancellation signals, and request-scoped values like the current user.

Routers and frameworks

Go's standard library net/http package received significant routing improvements in Go 1.22. The built-in ServeMux now supports method-based routing and path parameters, which were previously only available through third-party routers:

  
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUserHandler)
mux.HandleFunc("POST /users", createUserHandler)
  

For many applications, the standard library is now sufficient. However, third-party routers and frameworks remain popular for their additional features:

  • Chi is a lightweight, composable router built on net/http. It provides route grouping, middleware stacking, and URL parameters while staying close to the standard library. Chi handlers are standard http.Handler and http.HandlerFunc types, so you can mix Chi middleware with any net/http compatible code.
  • Gin is a full-featured framework with its own context type, built-in validation, and JSON rendering. It is one of the most popular Go web frameworks by GitHub stars. Gin's custom context type (*gin.Context) means middleware and handlers are Gin-specific and do not compose directly with standard library code.
  • Echo sits between Chi and Gin in terms of abstraction. It provides a custom context, middleware chaining, and data binding, but with less framework lock-in than Gin.
  • Standard library (net/http with Go 1.22+) is increasingly viable for production APIs. No external dependencies, no framework lock-in, and the middleware pattern works identically.

The examples in this guide use the standard library and Chi for clarity, but the authentication concepts apply to any router. If you use Gin or Echo, the patterns are the same; only the handler signatures differ.

Stateless vs. stateful authentication

Go applications commonly use one of two authentication models.

  • Stateless (JWT-based). The server issues a signed JSON Web Token after login. The client sends this token with every request, typically in the Authorization header. The server verifies the token's signature and claims without storing anything server-side. This is the dominant pattern for Go APIs and microservices.
  • Stateful (session-based). The server creates a session after login and stores it in a backend (Redis, PostgreSQL, or an in-memory store). The client receives a session ID in a cookie and sends it with every request. The server looks up the session to identify the user.

JWTs are the natural fit for Go services. Go is often chosen for APIs, microservices, and backend infrastructure where stateless authentication simplifies horizontal scaling. Sessions make more sense when you need immediate revocation, are building a server-rendered web application, or want to avoid the complexity of token refresh flows.

!!For more on JWT and Go, see How to handle JWT in Go.!!

Critical security considerations

Go provides meaningful advantages for security-critical code. The language is memory-safe, statically typed, and has no dynamic evaluation or prototype chain. Many vulnerability classes that affect JavaScript and Python applications simply do not exist in Go. But Go applications still have their own attack surfaces.

What Go protects you from (and what it does not)

Go's type system and compilation model eliminate several categories of vulnerabilities by design:

  • No prototype pollution. Go has no prototype chain. Structs have fixed fields defined at compile time. An attacker cannot inject unexpected properties into your user objects.
  • No eval or dynamic code execution. Go has no eval(), no exec(), and no runtime code generation from user input. The deserialization attacks that plague Python (pickle) and JavaScript (prototype pollution via JSON) have no direct equivalent.
  • No NoSQL operator injection. Go's strong typing means you cannot accidentally pass a map where a string is expected. The MongoDB query operator injection that affects loosely typed languages does not apply when your function signatures enforce types at compile time.
  • Memory safety. Go's garbage collector and bounds checking prevent buffer overflows and use-after-free vulnerabilities that affect C and C++ applications.

What Go does not protect you from: SQL injection (if you build queries with string concatenation), timing attacks on secret comparison, CSRF on cookie-based endpoints, misconfigured TLS, weak password hashing, and logic errors in your authorization checks. These require explicit defensive code.

Timing attacks and crypto/subtle

When comparing secrets (passwords, tokens, API keys, HMAC signatures), Go's == operator returns false as soon as it finds a mismatching byte. An attacker can measure these timing differences across many requests to deduce the secret character by character.

Go's standard library includes crypto/subtle, which provides constant-time comparison:

  
import "crypto/subtle"

// Vulnerable: timing leaks information
if token == storedToken {
    // ...
}

// Safe: constant-time comparison
if subtle.ConstantTimeCompare([]byte(token), []byte(storedToken)) == 1 {
    // ...
}
  

Use crypto/subtle.ConstantTimeCompare for any comparison involving secrets. This function takes the same amount of time regardless of where the inputs differ. Both inputs should be the same length; if they are not, hash them first.

Input validation and type safety

Go's type system is your first line of defense. Define explicit types for your request bodies and validate them before processing:

  
type LoginRequest struct {
    Email    string `json:"email"`
    Password string `json:"password"`
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
    var req LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request body", http.StatusBadRequest)
        return
    }

    // Validate fields explicitly
    if req.Email == "" || req.Password == "" {
        http.Error(w, "email and password are required", http.StatusBadRequest)
        return
    }

    if !isValidEmail(req.Email) {
        http.Error(w, "invalid email format", http.StatusBadRequest)
        return
    }

    // Now safe to use req.Email and req.Password
}
  

Go's json.Decoder only populates struct fields that exist in your type definition. Unknown fields are silently ignored by default, which prevents mass assignment attacks. If you want stricter validation, you can call decoder.DisallowUnknownFields() to reject payloads with unexpected keys.

For more complex validation, libraries like go-playground/validator provide struct tag-based validation:

  
type LoginRequest struct {
    Email    string `json:"email" validate:"required,email"`
    Password string `json:"password" validate:"required,min=8"`
}
  

SQL injection

Go's database/sql package uses parameterized queries by default, which prevents SQL injection when used correctly. The danger arises when you build queries through string concatenation:

  
// Vulnerable: string concatenation
query := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email)
db.QueryRow(query)

// Safe: parameterized query
db.QueryRow("SELECT * FROM users WHERE email = $1", email)
  

If you use an ORM like GORM or Ent, their query builders generate parameterized queries automatically. But be careful with raw SQL methods that these libraries also expose.

CSRF protection

If your Go application uses cookies for authentication (session cookies or JWTs stored in cookies), you need CSRF protection. Go provides no built-in CSRF middleware.

For APIs that use Bearer tokens in the Authorization header, CSRF is not a concern. Browsers do not attach custom headers to cross-origin requests automatically.

For cookie-based authentication, you have two options:

SameSite cookie attribute (simplest and recommended):

  
http.SetCookie(w, &http.Cookie{
    Name:     "session",
    Value:    sessionID,
    HttpOnly: true,
    Secure:   true,
    SameSite: http.SameSiteLaxMode,
    MaxAge:   7 * 24 * 60 * 60, // 7 days
})
  

CSRF tokens (for stricter protection or older browser support). The gorilla/csrf package provides double-submit cookie CSRF protection that integrates with any net/http compatible router:

  
import "github.com/gorilla/csrf"

csrfMiddleware := csrf.Protect(
    []byte("32-byte-long-auth-key-here12345"),
    csrf.Secure(true),
)

http.ListenAndServe(":8080", csrfMiddleware(router))
  

Setting SameSite: Lax on your session cookie is the most practical defense in 2026. It prevents cookies from being sent with cross-origin POST requests while still allowing normal navigation.

Password hashing

Go's extended library provides bcrypt and argon2 implementations. Always use async-safe, well-tested implementations and never store passwords in plain text:

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

// Hashing (during registration)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
    return fmt.Errorf("hashing password: %w", err)
}
// Store hash in your database

// Verification (during login)
err = bcrypt.CompareHashAndPassword(hash, []byte(password))
if err != nil {
    // Password does not match
}
  

bcrypt.DefaultCost is 10, which takes roughly 100ms per hash. For 2026 hardware, a cost of 12 (approximately 250 to 300ms) is more appropriate. You can set this explicitly:

  
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
  

Unlike Node.js, you do not need to worry about blocking an event loop. Go handles each request in its own goroutine, and the Go scheduler multiplexes goroutines across OS threads. A blocking bcrypt call in one goroutine does not prevent other goroutines from executing. However, under heavy load, many concurrent bcrypt operations can still saturate your CPU. Consider rate limiting login attempts to prevent this from becoming a denial-of-service vector.

If you want the latest in password hashing, golang.org/x/crypto/argon2 implements Argon2id, the winner of the Password Hashing Competition:

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

salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
    return err
}

hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
// Store both hash and salt
  

Argon2id is memory-hard, which makes it resistant to GPU-based attacks. It is a strong choice for new applications, though bcrypt remains the safe default if you want simplicity.

Cookie and session security

If you use cookies for authentication, configure them correctly:

  
http.SetCookie(w, &http.Cookie{
    Name:     "session_id",
    Value:    sessionID,
    Path:     "/",
    HttpOnly: true,                  // no JavaScript access
    Secure:   true,                  // HTTPS only
    SameSite: http.SameSiteLaxMode,  // CSRF protection
    MaxAge:   7 * 24 * 60 * 60,     // 7 days in seconds
})
  
  • HttpOnly prevents JavaScript from reading the cookie via document.cookie, protecting against XSS-based cookie theft.
  • Secure ensures cookies are only sent over HTTPS.
  • SameSite: Lax prevents cross-site request forgery for POST requests.

Security headers

Go has no single-package equivalent to Helmet in Node.js, but adding security headers is straightforward with middleware:

  
func securityHeaders(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("X-Content-Type-Options", "nosniff")
        w.Header().Set("X-Frame-Options", "DENY")
        w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
        w.Header().Set("Content-Security-Policy", "default-src 'self'")
        w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
        next.ServeHTTP(w, r)
    })
}
  

Apply this middleware at the top of your chain so every response includes these headers.

Supply chain security with Go modules

Go's module system provides stronger supply chain guarantees than most package ecosystems. The go.sum file contains cryptographic checksums for every dependency, and the Go module proxy (proxy.golang.org) and checksum database (sum.golang.org) verify that published modules have not been tampered with.

This means that unlike npm, where a compromised publish can replace a package version in place, Go's checksum database will flag any change to a previously published module version. The protections are real, but they are not absolute:

  • A malicious new version (not a modified existing version) will pass checksum verification.
  • Vendored dependencies bypass the module proxy.
  • Private modules hosted on internal registries are not covered by the public checksum database.

Best practices for dependency security in Go:

  • Run go mod verify in your CI pipeline to confirm that downloaded modules match their checksums.
  • Use go mod tidy to remove unused dependencies.
  • Audit new dependencies before adding them. Check maintenance activity and whether the module has known vulnerabilities.
  • Use govulncheck (the official Go vulnerability scanner) to check your code and dependencies against the Go vulnerability database.
  • Prefer the standard library and well-established modules from the Go team (golang.org/x/crypto, golang.org/x/oauth2) for security-critical code.

Authentication implementation approaches

Go offers several paths for implementing authentication, from building with standard library primitives to using session management libraries and managed providers. The right choice depends on your application's requirements and your team's appetite for managing auth infrastructure.

Approach 1: JWT authentication with golang-jwt

JWT-based authentication is the most common approach for Go APIs. The golang-jwt/jwt library provides robust token parsing, signing, and validation. For a deep dive into JWT handling in Go, including JWKS, key rotation, and claim validation, see How to handle JWT in Go.

Here is a complete example of JWT authentication with middleware:

  
package main

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "strings"
    "time"

    "github.com/golang-jwt/jwt/v5"
    "golang.org/x/crypto/bcrypt"
)

type contextKey string

const userContextKey contextKey = "user"

type Claims struct {
    jwt.RegisteredClaims
    Email string `json:"email"`
}

type TokenPair struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
}

var jwtSecret = []byte(os.Getenv("JWT_SECRET"))

// Issue tokens after successful login
func loginHandler(w http.ResponseWriter, r *http.Request) {
    var req LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    user, err := findUserByEmail(req.Email)
    if err != nil {
        http.Error(w, "invalid email or password", http.StatusUnauthorized)
        return
    }

    if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(req.Password)); err != nil {
        http.Error(w, "invalid email or password", http.StatusUnauthorized)
        return
    }

    accessToken, err := issueAccessToken(user)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    refreshToken, err := issueRefreshToken(user)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    // Store refresh token in an httpOnly cookie
    http.SetCookie(w, &http.Cookie{
        Name:     "refresh_token",
        Value:    refreshToken,
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteStrictMode,
        MaxAge:   7 * 24 * 60 * 60,
        Path:     "/auth/refresh",
    })

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(TokenPair{AccessToken: accessToken})
}

func issueAccessToken(user *User) (string, error) {
    claims := Claims{
        RegisteredClaims: jwt.RegisteredClaims{
            Subject:   user.ID,
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
        Email: user.Email,
    }
    return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret)
}

func issueRefreshToken(user *User) (string, error) {
    claims := jwt.RegisteredClaims{
        Subject:   user.ID,
        ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
        IssuedAt:  jwt.NewNumericDate(time.Now()),
    }
    return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret)
}

// Middleware: verify JWT on protected routes
func requireAuth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if !strings.HasPrefix(authHeader, "Bearer ") {
            http.Error(w, "missing or malformed token", http.StatusUnauthorized)
            return
        }

        tokenString := strings.TrimPrefix(authHeader, "Bearer ")
        claims := &Claims{}

        token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
            if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
            }
            return jwtSecret, nil
        })

        if err != nil || !token.Valid {
            http.Error(w, "invalid or expired token", http.StatusUnauthorized)
            return
        }

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

// Helper: retrieve the authenticated user from context
func userFromContext(ctx context.Context) (*Claims, bool) {
    claims, ok := ctx.Value(userContextKey).(*Claims)
    return claims, ok
}
  

Key decisions when building JWT auth in Go:

  • Access token lifetime. Keep it short (5 to 15 minutes). Short-lived tokens limit the damage if a token is leaked.
  • Refresh token storage. Store refresh tokens in HttpOnly cookies scoped to the refresh endpoint path, not in localStorage. Set the Path on the cookie to /auth/refresh so the browser only sends it to that endpoint.
  • Signing algorithm. Use RS256 (asymmetric) if multiple services need to verify tokens without sharing the signing secret. Use HS256 (symmetric) for simpler single-service setups. Always check the signing method in your key function to prevent algorithm confusion attacks.
  • Token revocation. JWTs are stateless, so you cannot revoke them once issued. For immediate revocation, maintain a blocklist of revoked token IDs (the jti claim) in Redis and check it during verification.

Consider this approach when you want full control, your auth requirements are straightforward (email/password login for an API), and your team is comfortable managing token lifecycle and refresh logic.

Approach 2: Session-based authentication

For server-rendered applications or same-domain SPAs where you want immediate session revocation without the complexity of JWT refresh flows, session-based authentication is a solid choice.

The alexedwards/scs package is a well-maintained, idiomatic session manager for Go. It integrates with net/http, supports multiple backends (Redis, PostgreSQL, MySQL, in-memory), and handles cookie management, expiration, and renewal:

  
package main

import (
    "encoding/json"
    "net/http"
    "time"

    "github.com/alexedwards/scs/redisstore"
    "github.com/alexedwards/scs/v2"
    "github.com/gomodule/redigo/redis"
    "golang.org/x/crypto/bcrypt"
)

var sessionManager *scs.SessionManager

func main() {
    pool := &redis.Pool{
        MaxIdle: 10,
        Dial: func() (redis.Conn, error) {
            return redis.Dial("tcp", "localhost:6379")
        },
    }

    sessionManager = scs.New()
    sessionManager.Store = redisstore.New(pool)
    sessionManager.Lifetime = 7 * 24 * time.Hour
    sessionManager.Cookie.HttpOnly = true
    sessionManager.Cookie.Secure = true
    sessionManager.Cookie.SameSite = http.SameSiteLaxMode

    mux := http.NewServeMux()
    mux.HandleFunc("POST /login", loginHandler)
    mux.HandleFunc("POST /logout", logoutHandler)
    mux.Handle("GET /dashboard", requireSession(http.HandlerFunc(dashboardHandler)))

    // Wrap the entire mux with the session middleware
    http.ListenAndServe(":8080", sessionManager.LoadAndSave(mux))
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
    var req LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    user, err := findUserByEmail(req.Email)
    if err != nil {
        http.Error(w, "invalid email or password", http.StatusUnauthorized)
        return
    }

    if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(req.Password)); err != nil {
        http.Error(w, "invalid email or password", http.StatusUnauthorized)
        return
    }

    // Regenerate the session token to prevent session fixation
    if err := sessionManager.RenewToken(r.Context()); err != nil {
        http.Error(w, "session error", http.StatusInternalServerError)
        return
    }

    sessionManager.Put(r.Context(), "user_id", user.ID)
    sessionManager.Put(r.Context(), "email", user.Email)

    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"message": "logged in"})
}

func logoutHandler(w http.ResponseWriter, r *http.Request) {
    if err := sessionManager.Destroy(r.Context()); err != nil {
        http.Error(w, "logout failed", http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusOK)
}

func requireSession(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        userID := sessionManager.GetString(r.Context(), "user_id")
        if userID == "" {
            http.Error(w, "not authenticated", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

func dashboardHandler(w http.ResponseWriter, r *http.Request) {
    userID := sessionManager.GetString(r.Context(), "user_id")
    json.NewEncoder(w).Encode(map[string]string{"user_id": userID})
}
  

Critical detail: session regeneration. Always call sessionManager.RenewToken() after successful login. Without this, an attacker who sets or predicts the session ID before login can hijack the authenticated session. This is session fixation, one of the most common session-related vulnerabilities.

Session store choices:

  • Redis (scs/redisstore): Fast (1 to 5ms lookups), supports TTL-based expiration. The standard choice for production.
  • PostgreSQL (scs/pgxstore): Useful if you want queryable session data and do not want to run Redis. Good for audit trails and active session management.
  • In-memory (scs/memstore): Suitable for development and testing only. Sessions are lost on restart and cannot be shared across instances.

Approach 3: OAuth2 with golang.org/x/oauth2

If your application needs to support social login (Google, GitHub, Microsoft) or integrate with external identity providers, Go's official golang.org/x/oauth2 package provides a clean implementation of the OAuth2 client flow:

  
package main

import (
    "context"
    "crypto/rand"
    "encoding/base64"
    "encoding/json"
    "net/http"

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

var googleOAuthConfig = &oauth2.Config{
    ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
    ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
    RedirectURL:  "http://localhost:8080/auth/google/callback",
    Scopes:       []string{"openid", "email", "profile"},
    Endpoint:     google.Endpoint,
}

func googleLoginHandler(w http.ResponseWriter, r *http.Request) {
    // Generate a random state parameter to prevent CSRF
    state := generateRandomState()
    sessionManager.Put(r.Context(), "oauth_state", state)

    url := googleOAuthConfig.AuthCodeURL(state)
    http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

func googleCallbackHandler(w http.ResponseWriter, r *http.Request) {
    // Verify the state parameter
    expectedState := sessionManager.GetString(r.Context(), "oauth_state")
    if r.URL.Query().Get("state") != expectedState {
        http.Error(w, "invalid state parameter", http.StatusBadRequest)
        return
    }
    sessionManager.Remove(r.Context(), "oauth_state")

    // Exchange the authorization code for tokens
    token, err := googleOAuthConfig.Exchange(context.Background(), r.URL.Query().Get("code"))
    if err != nil {
        http.Error(w, "token exchange failed", http.StatusInternalServerError)
        return
    }

    // Use the token to fetch user info
    client := googleOAuthConfig.Client(context.Background(), token)
    resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
    if err != nil {
        http.Error(w, "failed to fetch user info", http.StatusInternalServerError)
        return
    }
    defer resp.Body.Close()

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

    // Create or update the user in your database, then create a session
    // ...
}

func generateRandomState() string {
    b := make([]byte, 32)
    rand.Read(b)
    return base64.URLEncoding.EncodeToString(b)
}
  

The state parameter is critical. Without it, an attacker can initiate an OAuth flow and trick a victim into completing it, binding the attacker's external account to the victim's session. Always generate a cryptographically random state, store it in the session, and verify it in the callback. For more on protecting OAuth flows, see Understanding state, nonce, and PKCE.

This approach works for adding one or two social login providers. If you need to support multiple OAuth providers, enterprise SSO (SAML, OIDC), directory sync (SCIM), or multi-factor authentication, the complexity grows quickly. Each provider has its own token format, scope requirements, and error handling. This is where a managed provider becomes valuable.

Approach 4: Managed authentication

The approaches above all run inside your application. You manage the login flows, password hashing, session stores, token lifecycle, and every security edge case. A managed authentication provider handles all of this on external infrastructure and returns authenticated users to your application via a standard OAuth-style callback.

This approach makes sense when your team wants to focus on product development rather than maintaining authentication infrastructure, especially if you anticipate enterprise requirements like SSO, directory sync, or compliance certifications. Building those features in-house can take months.

WorkOS provides a Go SDK that integrates with the standard library:

  
package main

import (
    "net/http"
    "os"

    "github.com/workos/workos-go/v4/pkg/usermanagement"
)

func main() {
    um, err := usermanagement.NewClient(os.Getenv("WORKOS_API_KEY"))
    if err != nil {
        panic(err)
    }

    mux := http.NewServeMux()

    mux.HandleFunc("GET /auth/login", func(w http.ResponseWriter, r *http.Request) {
        url, err := um.GetAuthorizationURL(usermanagement.GetAuthorizationURLOpts{
            ClientID:    os.Getenv("WORKOS_CLIENT_ID"),
            RedirectURI: "http://localhost:8080/auth/callback",
            Provider:    "authkit",
        })
        if err != nil {
            http.Error(w, "failed to generate auth URL", http.StatusInternalServerError)
            return
        }
        http.Redirect(w, r, url.String(), http.StatusTemporaryRedirect)
    })

    mux.HandleFunc("GET /auth/callback", func(w http.ResponseWriter, r *http.Request) {
        code := r.URL.Query().Get("code")
        authResponse, err := um.AuthenticateWithCode(r.Context(), usermanagement.AuthenticateWithCodeOpts{
            ClientID: os.Getenv("WORKOS_CLIENT_ID"),
            Code:     code,
        })
        if err != nil {
            http.Error(w, "authentication failed", http.StatusUnauthorized)
            return
        }

        // authResponse contains the authenticated user, access token, and refresh token
        // Create a session or issue your own JWT
        // authResponse.User.ID, authResponse.User.Email, etc.
    })

    http.ListenAndServe(":8080", mux)
}
  

What WorkOS provides beyond basic authentication:

  • AuthKit. A customizable hosted login UI powered by Radix. Supports email/password, social login (Google, GitHub, Microsoft), magic auth, passkeys, and multi-factor authentication, all enabled through the dashboard without code changes.
  • Enterprise SSO. Native SAML and OIDC support, configurable by your customers through an Admin Portal. Your enterprise customers can set up their own identity provider connections without engineering involvement.
  • Directory sync. SCIM provisioning that automatically creates, updates, and deprovisioning users when your customers' IT teams make changes in their identity provider.
  • Role-based access control. Fine-grained permissions that scale from simple role checks to complex multi-tenant authorization.
  • Session management. Access and refresh tokens with secure cookie guidance, automatic token refresh, and instant session revocation.

The WorkOS Go SDK is idiomatic, built on net/http types and compatible with any router. You can also use the WorkOS CLI to automatically integrate AuthKit into your project with a single command.

Each capability builds on the previous one without rearchitecting. The authentication system you set up on day one scales to enterprise without major refactoring.

Production hardening

Authentication code in production needs more than correct logic. It needs to handle failure gracefully, resist abuse, and give your team visibility into what is happening.

Rate limiting

Rate limiting login endpoints is essential to prevent brute-force attacks and credential stuffing. Go's standard library includes golang.org/x/time/rate for token-bucket rate limiting:

  
import "golang.org/x/time/rate"

// Per-IP rate limiter
var loginLimiters = sync.Map{}

func getLoginLimiter(ip string) *rate.Limiter {
    if limiter, ok := loginLimiters.Load(ip); ok {
        return limiter.(*rate.Limiter)
    }
    limiter := rate.NewLimiter(rate.Every(time.Minute/5), 5) // 5 requests per minute
    loginLimiters.Store(ip, limiter)
    return limiter
}

func rateLimitLogin(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip := r.RemoteAddr
        if !getLoginLimiter(ip).Allow() {
            http.Error(w, "too many login attempts", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}
  

For production, use a Redis-backed rate limiter so limits are shared across instances. Five login attempts per minute per IP address is a reasonable starting point.

Context propagation

Go's context.Context is the idiomatic way to carry request-scoped data through your application. Use it to propagate the authenticated user from middleware to handlers to service layers:

  
type contextKey string

const userKey contextKey = "authenticated_user"

// In middleware: store the user
ctx := context.WithValue(r.Context(), userKey, authenticatedUser)

// In handler: retrieve the user
user, ok := r.Context().Value(userKey).(*User)
if !ok {
    http.Error(w, "unauthorized", http.StatusUnauthorized)
    return
}

// In service layer: accept context, retrieve user
func createOrder(ctx context.Context, order Order) error {
    user, ok := ctx.Value(userKey).(*User)
    if !ok {
        return errors.New("no authenticated user in context")
    }
    order.CreatedBy = user.ID
    // ...
}
  

Using a custom type for the context key (instead of a plain string) prevents collisions with keys from other packages. This is a small but important detail that is easy to overlook.

Structured logging for auth events

Log authentication events (successful logins, failed attempts, token refresh, session creation and destruction) with structured logging. This gives your team visibility into authentication patterns and makes incident investigation possible:

  
import "log/slog"

// On successful login
slog.Info("user authenticated",
    "user_id", user.ID,
    "email", user.Email,
    "method", "password",
    "ip", r.RemoteAddr,
)

// On failed login
slog.Warn("authentication failed",
    "email", req.Email,
    "reason", "invalid_password",
    "ip", r.RemoteAddr,
)
  

Use Go's log/slog package (standard library since Go 1.21) for structured logging. Never log full tokens, passwords, or session IDs. Log identifiers (user ID, token jti, IP address) and the reason for the event.

Graceful shutdown

When your server shuts down, in-flight authentication requests should complete rather than being abruptly terminated. Go's http.Server supports graceful shutdown:

  
srv := &http.Server{
    Addr:    ":8080",
    Handler: mux,
}

go func() {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
    <-sigChan

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    srv.Shutdown(ctx)
}()

srv.ListenAndServe()
  

This is especially important for authentication endpoints. An interrupted login flow can leave a user in a confusing state. A 30-second shutdown timeout gives in-flight requests time to complete while still allowing your deployment to proceed.

Choosing the right approach

The right authentication approach depends on where your application is today and where it is headed.

  • Build JWT auth yourself if you are building a straightforward API, your team understands token lifecycle management, and you do not anticipate needing enterprise features like SSO or SCIM in the near term. This gives you full control and no external dependencies beyond golang-jwt/jwt.
  • Use session-based auth if you are building a server-rendered application, need immediate session revocation, or want to avoid the complexity of token refresh logic. The alexedwards/scs package is mature, well-documented, and integrates cleanly with the standard library.
  • Use a managed provider if you want to ship authentication quickly, need enterprise features (SSO, directory sync, MFA, passkeys), or do not want to own the security burden of managing authentication infrastructure. WorkOS provides a comprehensive platform that handles the full authentication stack, from hosted login UI to enterprise SSO, with a Go SDK that integrates with your existing net/http code.

Authentication is critical infrastructure. Whether you build it yourself or choose a partner, invest the time to get it right. Your future self and your enterprise customers will thank you.

Sign up for WorkOS today and secure your Go app.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.