OAuth
This builds on Users & Sessions. You’ll add OAuth login using Authorization Code + PKCE with a provider-agnostic structure, then configure Google first.
Before you start
Section titled “Before you start”- Go 1.22+
- Existing sessions and cookies from Users & Sessions
- A Google OAuth app with client ID/secret
- Callback URL configured in Google console (for local dev:
http://localhost:8080/oauth/google/callback)
File layout
Section titled “File layout”/auth /sessions.go /cookies.go /middleware.go /oauth.go // provider account linking + user creation /oauth_pkce.go // PKCE verifier + state cookie helpers/handlers /oauth.go // oauth start + callback handlers /pages.go // login page with oauth button/main.goOAuth accounts table
Section titled “OAuth accounts table”Store provider identities separately from users so one user can link multiple providers.
CREATE TABLE oauth_accounts ( id TEXT NOT NULL PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), provider TEXT NOT NULL, provider_user_id TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(provider, provider_user_id));
CREATE INDEX oauth_accounts_user_id_idx ON oauth_accounts(user_id);Configure Google OAuth (PKCE)
Section titled “Configure Google OAuth (PKCE)”Put this in main.go startup:
import ( "os"
"golang.org/x/oauth2" "golang.org/x/oauth2/google")
func googleOAuthConfig() *oauth2.Config { return &oauth2.Config{ ClientID: os.Getenv("GOOGLE_CLIENT_ID"), ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), RedirectURL: os.Getenv("GOOGLE_CALLBACK_URL"), Scopes: []string{"openid", "email", "profile"}, Endpoint: google.Endpoint, }}Environment variables:
GOOGLE_CLIENT_IDGOOGLE_CLIENT_SECRETGOOGLE_CALLBACK_URLOAUTH_STATE_SECRET
PKCE + state helpers
Section titled “PKCE + state helpers”Put this in auth/oauth_pkce.go:
package auth
import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "net/http" "os" "strings" "time")
const oauthStateCookieName = "oauth_state"
func NewRandomURLSafe(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil}
func PKCEChallenge(verifier string) string { sum := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(sum[:])}
func SignOAuthState(state, verifier string) (string, error) { secret := os.Getenv("OAUTH_STATE_SECRET") if secret == "" { return "", errors.New("missing OAUTH_STATE_SECRET") } payload := state + "." + verifier mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(payload)) sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) return payload + "." + sig, nil}
func VerifyOAuthStateCookie(raw string) (state string, verifier string, err error) { parts := strings.Split(raw, ".") if len(parts) != 3 { return "", "", errors.New("invalid oauth state cookie") } payload := parts[0] + "." + parts[1] secret := os.Getenv("OAUTH_STATE_SECRET") mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(payload)) expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(parts[2]), []byte(expected)) { return "", "", errors.New("oauth state signature mismatch") } return parts[0], parts[1], nil}
func SetOAuthStateCookie(w http.ResponseWriter, value string) { http.SetCookie(w, &http.Cookie{ Name: oauthStateCookieName, Value: value, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 300, })}
func ReadOAuthStateCookie(r *http.Request) (string, error) { c, err := r.Cookie(oauthStateCookieName) if err != nil { return "", fmt.Errorf("missing oauth state cookie: %w", err) } return c.Value, nil}
func ClearOAuthStateCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: oauthStateCookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(0, 0), })}Provider-agnostic auth logic
Section titled “Provider-agnostic auth logic”Put this in auth/oauth.go:
package auth
import ( "context" "database/sql" "errors" "time"
gonanoid "github.com/matoous/go-nanoid/v2")
func GetOrCreateUserFromOAuth(ctx context.Context, db *sql.DB, provider, providerUserID, email string) (string, error) { userID, err := getUserIDByOAuthAccount(ctx, db, provider, providerUserID) if err != nil { return "", err } if userID != "" { return userID, nil }
newUserID, err := gonanoid.New(12) if err != nil { return "", err }
oauthAccountID, err := gonanoid.New() if err != nil { return "", err }
now := time.Now().UTC().Unix()
tx, err := db.BeginTx(ctx, nil) if err != nil { return "", err } defer tx.Rollback()
_, err = tx.ExecContext(ctx, "INSERT INTO users (id, created_at) VALUES (?, ?)", newUserID, now, ) if err != nil { return "", err }
_, err = tx.ExecContext(ctx, "INSERT INTO oauth_accounts (id, user_id, provider, provider_user_id, email, created_at) VALUES (?, ?, ?, ?, ?, ?)", oauthAccountID, newUserID, provider, providerUserID, email, now, ) if err != nil { return "", err }
if err := tx.Commit(); err != nil { return "", err }
return newUserID, nil}
func getUserIDByOAuthAccount(ctx context.Context, db *sql.DB, provider, providerUserID string) (string, error) { row := db.QueryRowContext(ctx, "SELECT user_id FROM oauth_accounts WHERE provider = ? AND provider_user_id = ?", provider, providerUserID, )
var userID string err := row.Scan(&userID) if errors.Is(err, sql.ErrNoRows) { return "", nil } if err != nil { return "", err }
return userID, nil}OAuth handlers
Section titled “OAuth handlers”Put this in handlers/oauth.go:
package handlers
import ( "encoding/json" "database/sql" "fmt" "io" "net/http"
auth "github.com/.../auth" "golang.org/x/oauth2")
var allowedOAuthProviders = map[string]struct{}{ "google": {},}
func readProvider(r *http.Request) (string, bool) { provider := r.PathValue("provider") _, ok := allowedOAuthProviders[provider] return provider, ok}
func HandleOAuthStart(googleCfg *oauth2.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { provider, ok := readProvider(r) if !ok { http.NotFound(w, r) return } if provider != "google" { http.NotFound(w, r) return }
state, err := auth.NewRandomURLSafe(32) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } verifier, err := auth.NewRandomURLSafe(48) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return }
signedState, err := auth.SignOAuthState(state, verifier) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } auth.SetOAuthStateCookie(w, signedState)
challenge := auth.PKCEChallenge(verifier) url := googleCfg.AuthCodeURL( state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("code_challenge", challenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"), ) http.Redirect(w, r, url, http.StatusFound) }}
type googleUserInfo struct { Sub string `json:"sub"` Email string `json:"email"`}
func fetchGoogleUserInfo(client *http.Client) (*googleUserInfo, error) { resp, err := client.Get("https://openidconnect.googleapis.com/v1/userinfo") if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("userinfo request failed: %s", string(body)) } var user googleUserInfo if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return nil, err } return &user, nil}
func HandleOAuthCallback(db *sql.DB, googleCfg *oauth2.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { provider, ok := readProvider(r) if !ok { http.NotFound(w, r) return } if provider != "google" { http.NotFound(w, r) return }
rawCookie, err := auth.ReadOAuthStateCookie(r) if err != nil { http.Error(w, "OAuth state missing", http.StatusUnauthorized) return } auth.ClearOAuthStateCookie(w)
expectedState, verifier, err := auth.VerifyOAuthStateCookie(rawCookie) if err != nil { http.Error(w, "OAuth state invalid", http.StatusUnauthorized) return } if r.URL.Query().Get("state") != expectedState { http.Error(w, "OAuth state mismatch", http.StatusUnauthorized) return }
code := r.URL.Query().Get("code") if code == "" { http.Error(w, "OAuth code missing", http.StatusUnauthorized) return }
tok, err := googleCfg.Exchange( r.Context(), code, oauth2.SetAuthURLParam("code_verifier", verifier), ) if err != nil { http.Error(w, "OAuth login failed", http.StatusUnauthorized) return }
googleHTTPClient := googleCfg.Client(r.Context(), tok) profile, err := fetchGoogleUserInfo(googleHTTPClient) if err != nil { http.Error(w, "OAuth login failed", http.StatusUnauthorized) return } if profile.Sub == "" { http.Error(w, "OAuth account missing user id", http.StatusUnauthorized) return }
userID, err := auth.GetOrCreateUserFromOAuth(r.Context(), db, provider, profile.Sub, profile.Email) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return }
token, err := auth.CreateSession(r.Context(), db, userID) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return }
auth.SetSessionCookie(w, token) http.Redirect(w, r, "/dashboard", http.StatusSeeOther) }}Login page
Section titled “Login page”Put this in handlers/pages.go:
func HandleLoginPage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`<!doctype html><html> <body> <h1>Log in</h1> <p><a href="/oauth/google">Continue with Google</a></p> <p><a href="/signup">Use email and password instead</a></p> </body></html>`))}If your app is OAuth-only, remove the email/password link.
Route wiring
Section titled “Route wiring”Put this in main.go:
func main() { googleCfg := googleOAuthConfig()
mux := http.NewServeMux()
mux.HandleFunc("GET /login", handlers.HandleLoginPage)
mux.HandleFunc("GET /oauth/{provider}", handlers.HandleOAuthStart(googleCfg)) mux.HandleFunc("GET /oauth/{provider}/callback", handlers.HandleOAuthCallback(db, googleCfg))
handler := auth.SessionMiddleware(db)(mux) http.ListenAndServe(":8080", handler)}Do not apply same-origin checks to OAuth callback routes. The provider redirects from a different origin by design.
Add more providers
Section titled “Add more providers”The structure stays the same for any provider:
- Add an
oauth2.Configfor the provider and wire it into start/callback handlers - Add provider name to
allowedOAuthProviders - Add provider callback URL in that provider’s console
- Add a login button/link (
/oauth/{provider})
Next up
Section titled “Next up”Head to Rate Limiting to add a second factor for higher-risk accounts.