June 15, 2025

Implementing OAuth2 Flow Manually in Go Without Any Library

Learn how to implement the OAuth2 Authorization Code Flow from scratch in Go using only the standard library, with complete examples and explanations.

OAuth2 is the backbone of modern authentication and authorization flows — especially in web and mobile applications. While Go developers often rely on libraries like golang.org/x/oauth2 to handle the protocol, there's immense value in understanding how it actually works under the hood.

In this post, we'll manually implement the OAuth2 Authorization Code Flow in Go — no third-party libraries, no magic. Just pure HTTP handling, tokens, and redirects. By the end, you'll have a solid grasp of each step in the OAuth2 lifecycle.

A Quick Overview of OAuth2 Authorization Code Flow

Before diving into code, let's briefly outline the flow:

  1. User Clicks "Login with Provider"
  2. User is Redirected to the Authorization Server (e.g., Google, GitHub)
  3. User Logs in and Grants Access
  4. Authorization Server Redirects Back with a code
  5. Your Server Exchanges the Code for an Access Token
  6. You Use the Token to Access User Info or Protected APIs

Let's implement this using Google as the OAuth2 provider — but this pattern is similar across all providers (GitHub, Facebook, etc.).

Setup

Create a simple Go web server with three endpoints:

  • /login - redirects to Google
  • /callback - handles Google's response
  • /profile - fetches user info using the access token

First, get your OAuth2 credentials from Google Developer Console:

  • Client ID
  • Client Secret
  • Redirect URI (e.g., http://localhost:8080/callback)

Basic Boilerplate

package main

import (
    "fmt"
    "log"
    "net/http"
    "net/url"
    "io"
    "encoding/json"
    "strings"
)

const (
    clientID     = "YOUR_CLIENT_ID"
    clientSecret = "YOUR_CLIENT_SECRET"
    redirectURI  = "http://localhost:8080/callback"
    authURL      = "https://accounts.google.com/o/oauth2/v2/auth"
    tokenURL     = "https://oauth2.googleapis.com/token"
    userInfoURL  = "https://www.googleapis.com/oauth2/v2/userinfo"
)

Step 1: Redirecting to the Authorization Server

func handleLogin(w http.ResponseWriter, r *http.Request) {
    q := url.Values{}
    q.Add("client_id", clientID)
    q.Add("redirect_uri", redirectURI)
    q.Add("response_type", "code")
    q.Add("scope", "email profile")
    q.Add("access_type", "offline")
    q.Add("prompt", "consent")

    http.Redirect(w, r, authURL+"?"+q.Encode(), http.StatusFound)
}

Step 2: Handling the Callback

Once the user logs in with Google, they're redirected back to your /callback endpoint with a code.

func handleCallback(w http.ResponseWriter, r *http.Request) {
    code := r.URL.Query().Get("code")
    if code == "" {
        http.Error(w, "No code in request", http.StatusBadRequest)
        return
    }

    data := url.Values{}
    data.Set("code", code)
    data.Set("client_id", clientID)
    data.Set("client_secret", clientSecret)
    data.Set("redirect_uri", redirectURI)
    data.Set("grant_type", "authorization_code")

    resp, err := http.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
    if err != nil {
        http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
        return
    }
    defer resp.Body.Close()

    var tokenResp map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&tokenResp)

    accessToken, ok := tokenResp["access_token"].(string)
    if !ok {
        http.Error(w, "No access token in response", http.StatusInternalServerError)
        return
    }

    // Store token temporarily (in-memory for now)
    http.Redirect(w, r, "/profile?token="+accessToken, http.StatusFound)
}

Step 3: Fetching User Info

func handleProfile(w http.ResponseWriter, r *http.Request) {
    token := r.URL.Query().Get("token")
    if token == "" {
        http.Error(w, "Missing access token", http.StatusUnauthorized)
        return
    }

    req, _ := http.NewRequest("GET", userInfoURL, nil)
    req.Header.Add("Authorization", "Bearer "+token)

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        http.Error(w, "Failed to fetch user info", http.StatusInternalServerError)
        return
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    w.Header().Set("Content-Type", "application/json")
    w.Write(body)
}

Final Server

func main() {
    http.HandleFunc("/login", handleLogin)
    http.HandleFunc("/callback", handleCallback)
    http.HandleFunc("/profile", handleProfile)

    fmt.Println("Server started at http://localhost:8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Testing the Flow

  1. Open http://localhost:8080/login
  2. Grant access via Google
  3. You'll be redirected to /profile and see your user info in JSON

What's Missing in This Minimal Flow?

This is a minimal and insecure implementation. In a production setup, you must also:

  • Verify the state parameter to prevent CSRF attacks.
  • Use HTTPS instead of HTTP.
  • Securely store tokens (e.g., in a session or database).
  • Handle refresh tokens for long-lived sessions.
  • Handle token expiration and revocation.

Advanced Security Enhancements

Adding CSRF Protection with State Parameter

import (
    "crypto/rand"
    "encoding/base64"
)

// Generate a random state for CSRF protection
func generateState() string {
    b := make([]byte, 32)
    rand.Read(b)
    return base64.StdEncoding.EncodeToString(b)
}

func handleLogin(w http.ResponseWriter, r *http.Request) {
    state := generateState()

    // Store state in session or cookie (simplified here)
    http.SetCookie(w, &http.Cookie{
        Name:     "oauth_state",
        Value:    state,
        HttpOnly: true,
        Secure:   true, // Use in production with HTTPS
    })

    q := url.Values{}
    q.Add("client_id", clientID)
    q.Add("redirect_uri", redirectURI)
    q.Add("response_type", "code")
    q.Add("scope", "email profile")
    q.Add("access_type", "offline")
    q.Add("prompt", "consent")
    q.Add("state", state) // Add state parameter

    http.Redirect(w, r, authURL+"?"+q.Encode(), http.StatusFound)
}

func handleCallback(w http.ResponseWriter, r *http.Request) {
    // Verify state parameter
    stateCookie, err := r.Cookie("oauth_state")
    if err != nil || stateCookie.Value != r.URL.Query().Get("state") {
        http.Error(w, "Invalid state parameter", http.StatusBadRequest)
        return
    }

    // Clear the state cookie
    http.SetCookie(w, &http.Cookie{
        Name:     "oauth_state",
        Value:    "",
        MaxAge:   -1,
        HttpOnly: true,
    })

    // Rest of the callback logic...
}

Token Storage and Session Management

import (
    "github.com/gorilla/sessions"
    "time"
)

var store = sessions.NewCookieStore([]byte("your-secret-key"))

func handleCallback(w http.ResponseWriter, r *http.Request) {
    // ... existing code for token exchange ...

    // Store token in session instead of URL parameter
    session, _ := store.Get(r, "session")
    session.Values["access_token"] = accessToken
    session.Values["expires_at"] = time.Now().Add(time.Hour).Unix()
    session.Save(r, w)

    http.Redirect(w, r, "/profile", http.StatusFound)
}

func handleProfile(w http.ResponseWriter, r *http.Request) {
    session, _ := store.Get(r, "session")

    token, ok := session.Values["access_token"].(string)
    if !ok {
        http.Error(w, "No access token in session", http.StatusUnauthorized)
        return
    }

    // Check token expiration
    if expiresAt, ok := session.Values["expires_at"].(int64); ok {
        if time.Now().Unix() > expiresAt {
            http.Error(w, "Token expired", http.StatusUnauthorized)
            return
        }
    }

    // ... rest of the profile logic ...
}

Error Handling and Logging

import (
    "log"
)

func handleCallback(w http.ResponseWriter, r *http.Request) {
    code := r.URL.Query().Get("code")
    if code == "" {
        log.Printf("OAuth callback missing code parameter")
        http.Error(w, "Authorization failed", http.StatusBadRequest)
        return
    }

    // Check for error parameter from OAuth provider
    if errorParam := r.URL.Query().Get("error"); errorParam != "" {
        log.Printf("OAuth error: %s - %s", errorParam, r.URL.Query().Get("error_description"))
        http.Error(w, "Authorization denied", http.StatusForbidden)
        return
    }

    // ... token exchange logic with better error handling ...

    resp, err := http.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
    if err != nil {
        log.Printf("Failed to exchange token: %v", err)
        http.Error(w, "Authentication failed", http.StatusInternalServerError)
        return
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        log.Printf("Token exchange failed with status: %d", resp.StatusCode)
        http.Error(w, "Authentication failed", http.StatusInternalServerError)
        return
    }

    // ... rest of the logic ...
}

Supporting Multiple OAuth Providers

type OAuthProvider struct {
    ClientID     string
    ClientSecret string
    AuthURL      string
    TokenURL     string
    UserInfoURL  string
    Scopes       []string
}

var providers = map[string]OAuthProvider{
    "google": {
        ClientID:     "YOUR_GOOGLE_CLIENT_ID",
        ClientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
        AuthURL:      "https://accounts.google.com/o/oauth2/v2/auth",
        TokenURL:     "https://oauth2.googleapis.com/token",
        UserInfoURL:  "https://www.googleapis.com/oauth2/v2/userinfo",
        Scopes:       []string{"email", "profile"},
    },
    "github": {
        ClientID:     "YOUR_GITHUB_CLIENT_ID",
        ClientSecret: "YOUR_GITHUB_CLIENT_SECRET",
        AuthURL:      "https://github.com/login/oauth/authorize",
        TokenURL:     "https://github.com/login/oauth/access_token",
        UserInfoURL:  "https://api.github.com/user",
        Scopes:       []string{"user:email"},
    },
}

func handleLoginWithProvider(w http.ResponseWriter, r *http.Request) {
    providerName := r.URL.Query().Get("provider")
    provider, exists := providers[providerName]
    if !exists {
        http.Error(w, "Unsupported provider", http.StatusBadRequest)
        return
    }

    state := generateState()
    http.SetCookie(w, &http.Cookie{
        Name:     "oauth_state",
        Value:    state,
        HttpOnly: true,
    })

    q := url.Values{}
    q.Add("client_id", provider.ClientID)
    q.Add("redirect_uri", redirectURI)
    q.Add("response_type", "code")
    q.Add("scope", strings.Join(provider.Scopes, " "))
    q.Add("state", state)

    http.Redirect(w, r, provider.AuthURL+"?"+q.Encode(), http.StatusFound)
}

Complete Production-Ready Example

Here's a more robust implementation with all the security features:

package main

import (
    "crypto/rand"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "strings"
    "time"
)

type TokenResponse struct {
    AccessToken  string `json:"access_token"`
    TokenType    string `json:"token_type"`
    ExpiresIn    int    `json:"expires_in"`
    RefreshToken string `json:"refresh_token,omitempty"`
}

type UserInfo struct {
    ID            string `json:"id"`
    Email         string `json:"email"`
    Name          string `json:"name"`
    Picture       string `json:"picture"`
    VerifiedEmail bool   `json:"verified_email"`
}

const (
    clientID     = "YOUR_CLIENT_ID"
    clientSecret = "YOUR_CLIENT_SECRET"
    redirectURI  = "http://localhost:8080/callback"
    authURL      = "https://accounts.google.com/o/oauth2/v2/auth"
    tokenURL     = "https://oauth2.googleapis.com/token"
    userInfoURL  = "https://www.googleapis.com/oauth2/v2/userinfo"
)

var sessions = make(map[string]map[string]interface{})

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

func generateSessionID() string {
    b := make([]byte, 16)
    rand.Read(b)
    return base64.StdEncoding.EncodeToString(b)
}

func handleLogin(w http.ResponseWriter, r *http.Request) {
    state := generateState()
    sessionID := generateSessionID()

    // Store state in memory (use Redis/database in production)
    sessions[sessionID] = map[string]interface{}{
        "state":      state,
        "created_at": time.Now(),
    }

    http.SetCookie(w, &http.Cookie{
        Name:     "session_id",
        Value:    sessionID,
        HttpOnly: true,
        Secure:   false, // Set to true in production with HTTPS
        Path:     "/",
    })

    q := url.Values{}
    q.Add("client_id", clientID)
    q.Add("redirect_uri", redirectURI)
    q.Add("response_type", "code")
    q.Add("scope", "email profile")
    q.Add("access_type", "offline")
    q.Add("prompt", "consent")
    q.Add("state", state)

    authURLWithParams := authURL + "?" + q.Encode()
    http.Redirect(w, r, authURLWithParams, http.StatusFound)
}

func handleCallback(w http.ResponseWriter, r *http.Request) {
    // Get session
    sessionCookie, err := r.Cookie("session_id")
    if err != nil {
        http.Error(w, "No session found", http.StatusUnauthorized)
        return
    }

    session, exists := sessions[sessionCookie.Value]
    if !exists {
        http.Error(w, "Invalid session", http.StatusUnauthorized)
        return
    }

    // Verify state parameter
    expectedState, ok := session["state"].(string)
    if !ok || expectedState != r.URL.Query().Get("state") {
        http.Error(w, "Invalid state parameter", http.StatusBadRequest)
        return
    }

    // Check for authorization errors
    if errorParam := r.URL.Query().Get("error"); errorParam != "" {
        log.Printf("OAuth error: %s - %s", errorParam, r.URL.Query().Get("error_description"))
        http.Error(w, "Authorization denied", http.StatusForbidden)
        return
    }

    code := r.URL.Query().Get("code")
    if code == "" {
        http.Error(w, "No authorization code received", http.StatusBadRequest)
        return
    }

    // Exchange code for token
    data := url.Values{}
    data.Set("code", code)
    data.Set("client_id", clientID)
    data.Set("client_secret", clientSecret)
    data.Set("redirect_uri", redirectURI)
    data.Set("grant_type", "authorization_code")

    resp, err := http.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
    if err != nil {
        log.Printf("Failed to exchange token: %v", err)
        http.Error(w, "Authentication failed", http.StatusInternalServerError)
        return
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        log.Printf("Token exchange failed: %s", string(body))
        http.Error(w, "Authentication failed", http.StatusInternalServerError)
        return
    }

    var tokenResp TokenResponse
    if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
        log.Printf("Failed to decode token response: %v", err)
        http.Error(w, "Authentication failed", http.StatusInternalServerError)
        return
    }

    // Store token in session
    session["access_token"] = tokenResp.AccessToken
    session["token_type"] = tokenResp.TokenType
    session["expires_at"] = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
    if tokenResp.RefreshToken != "" {
        session["refresh_token"] = tokenResp.RefreshToken
    }

    sessions[sessionCookie.Value] = session

    http.Redirect(w, r, "/profile", http.StatusFound)
}

func handleProfile(w http.ResponseWriter, r *http.Request) {
    sessionCookie, err := r.Cookie("session_id")
    if err != nil {
        http.Error(w, "No session found", http.StatusUnauthorized)
        return
    }

    session, exists := sessions[sessionCookie.Value]
    if !exists {
        http.Error(w, "Invalid session", http.StatusUnauthorized)
        return
    }

    accessToken, ok := session["access_token"].(string)
    if !ok {
        http.Error(w, "No access token in session", http.StatusUnauthorized)
        return
    }

    // Check token expiration
    if expiresAt, ok := session["expires_at"].(time.Time); ok {
        if time.Now().After(expiresAt) {
            http.Error(w, "Token expired", http.StatusUnauthorized)
            return
        }
    }

    // Fetch user info
    req, err := http.NewRequest("GET", userInfoURL, nil)
    if err != nil {
        http.Error(w, "Failed to create request", http.StatusInternalServerError)
        return
    }

    req.Header.Add("Authorization", "Bearer "+accessToken)

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        log.Printf("Failed to fetch user info: %v", err)
        http.Error(w, "Failed to fetch user info", http.StatusInternalServerError)
        return
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        http.Error(w, "Failed to fetch user info", http.StatusInternalServerError)
        return
    }

    var userInfo UserInfo
    if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
        http.Error(w, "Failed to decode user info", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(userInfo)
}

func handleLogout(w http.ResponseWriter, r *http.Request) {
    sessionCookie, err := r.Cookie("session_id")
    if err == nil {
        delete(sessions, sessionCookie.Value)
    }

    http.SetCookie(w, &http.Cookie{
        Name:     "session_id",
        Value:    "",
        MaxAge:   -1,
        HttpOnly: true,
        Path:     "/",
    })

    fmt.Fprintf(w, "Logged out successfully")
}

func main() {
    http.HandleFunc("/login", handleLogin)
    http.HandleFunc("/callback", handleCallback)
    http.HandleFunc("/profile", handleProfile)
    http.HandleFunc("/logout", handleLogout)

    fmt.Println("Server started at http://localhost:8080")
    fmt.Println("Make sure to configure your OAuth2 credentials!")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Next Steps

To take this further, consider:

  • Implementing PKCE for mobile applications
  • Adding refresh token rotation for enhanced security
  • Supporting multiple OAuth providers (GitHub, Facebook, etc.)
  • Implementing proper session storage with Redis or database
  • Adding comprehensive logging and monitoring
  • Writing tests for all authentication flows

Remember: while this manual implementation is great for learning, always use battle-tested libraries like golang.org/x/oauth2 in production applications for security and maintainability.

Key takeaways from this implementation:

  • OAuth2 is just HTTP requests - No magic, just well-defined API calls
  • Security is paramount - Always use HTTPS, state parameters, and secure token storage
  • Error handling matters - Handle all edge cases and failure scenarios
  • Session management is crucial - Proper token storage and expiration handling
  • Flexibility through understanding - Knowing the flow lets you customize as needed

Whether you're building internal tools, integrating third-party APIs, or securing your own applications, this knowledge gives you control and confidence in implementing secure authentication flows.

Conclusion

By implementing the OAuth2 flow manually, we've peeled back the abstraction and gained real insight into how the protocol works. While libraries are essential in production, understanding the underlying HTTP flow makes you a stronger developer and helps debug authentication issues when things go wrong.

Tags

#Go
#OAuth2
#Authentication
#Security
Published on June 15, 2025