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:
- User Clicks "Login with Provider"
- User is Redirected to the Authorization Server (e.g., Google, GitHub)
- User Logs in and Grants Access
- Authorization Server Redirects Back with a code
- Your Server Exchanges the Code for an Access Token
- 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
- Open
http://localhost:8080/login
- Grant access via Google
- 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.