OAuth 2.0 PKCE Flow in Go

A guide to Google authentication
Mar 4th 2025

This post explains how to build the core of a Google OAuth 2.0 PKCE authentication flow in Go. It focuses on three tasks: setting up the server, saving data to a database and managing user sessions. When you finish, you will understand the flow and have a base for your own projects.

For more background on how the flow works, I recommend this guide by Pilcrow. This article is a port to Go and an expansion of one of his Next.js OAuth examples.

Before we begin, get your Google client ID and client secret. Set http://localhost:3000/login/google/callback as the redirectURI.

Also install Go, air, and SQLite.

Server setup

The backend needs four endpoints:

  1. /login/google to redirect the user to Google.
  2. /login/google/callback to get the callback from Google.
  3. /login/session to get check the state of the session.
  4. /logout to let the user logout and delete the session.

To start, set up the project:

terminal
go mod init github.com/yourusername/oauth2 && air init

Create the server:

server.go
package main

import (
	"log/slog"
	"net/http"
)

func server() error {
	port := ":3000"
	mux := http.NewServeMux()
	mux.HandleFunc("GET /login/google", login)
	mux.HandleFunc("GET /login/google/callback", callback)
	mux.HandleFunc("GET /login/session", session)
	mux.HandleFunc("POST /logout", logout)
	slog.Info("Listening", "port", port)
	return http.ListenAndServe(port, mux)
}

// Placeholder handlers for now
func login(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Login"))
}

func callback(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Callback"))
}

func session(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Session"))
}

func logout(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Logout"))
}

Start it in main.go:

main.go
package main

import (
	"log/slog"
	"os"
)

func main() {
	if err := server(); err != nil {
		slog.Error("Error starting server", "err", err)
		os.Exit(1)
	}
}

Check that it works:

terminal-1
 air
2025/01/04 16:59:44 INFO Listening port=:3000
terminal-2
 curl localhost:3000/login/google
Login%
 curl -X POST localhost:3000/logout
Logout%

Set the environment variables:

.env
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

Install godotenv and import it in your server:

terminal
 go get github.com/joho/godotenv
go: added github.com/joho/godotenv v1.5.1
server.go
import (
	"log/slog"
	"net/http"

	_ "github.com/joho/godotenv/autoload"
)

Login endpoint

In this step we'll create the login endpoint and redirect the user to Google.

Four pieces of information go in the query parameters of Google's OAuth URL:

  1. Our clientID.
  2. The redirectURI of our server (callback URL).
  3. A token called state.
  4. A hash of codeVerifier (another token), called codeChallenge.

We must generate the state and codeVerifier ourselves:

server.go
func createState() (string, error) {
    bytes := make([]byte, 16)
    if _, err := rand.Read(bytes); err != nil {
        return "", err
    }
    return base64.URLEncoding.EncodeToString(bytes), nil
}

func createCodeVerifier() (string, error) {
    bytes := make([]byte, 32)
    if _, err := rand.Read(bytes); err != nil {
        return "", err
    }
    return base64.RawURLEncoding.EncodeToString(bytes), nil
}

func createS256CodeChallenge(codeVerifier string) string {
    hash := sha256.Sum256([]byte(codeVerifier))
    return base64.RawURLEncoding.EncodeToString(hash[:])
}

Use RawURLEncoding to create the codeVerifier to comply with the PKCE specification.

To create the codeChallenge we use the SHA256 algorithm.

Use constants for Google's URLs and the names of the cookies:

server.go
const (
	googleAuthURL          = "https://accounts.google.com/o/oauth2/v2/auth"
	googleTokenURL         = "https://oauth2.googleapis.com/token"
	googleUserInfoURL      = "https://www.googleapis.com/oauth2/v2/userinfo"
	stateCookieName        = "google_oauth_state"
	codeVerifierCookieName = "google_code_verifier"
)

The login handler now looks like this:

server.go
func login(w http.ResponseWriter, r *http.Request) {
	// The redirect URI has to be the same url as the one you registered
    // when getting the credentials
	redirectURI := "http://localhost:3000/login/google/callback"
	clientID := os.Getenv("GOOGLE_CLIENT_ID")

	state, err := createState()
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	codeVerifier, err := createCodeVerifier()
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	codeChallenge := createS256CodeChallenge(codeVerifier)

	authUrl, err := url.Parse(googleAuthURL)
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	query := authUrl.Query()

	query.Set("response_type", "code")
	query.Set("client_id", clientID)
	query.Set("redirect_uri", redirectURI)
	query.Set("state", state)
	query.Set("code_challenge_method", "S256")
	query.Set("code_challenge", codeChallenge)
	query.Set("scope", "profile email")

	authUrl.RawQuery = query.Encode()

	http.SetCookie(w, &http.Cookie{
		Name:     stateCookieName,
		Value:    state,
		MaxAge:   int(10 * time.Minute),
		HttpOnly: true,
		Secure:   false, // TODO: set to true for https
		SameSite: http.SameSiteLaxMode,
	})

	http.SetCookie(w, &http.Cookie{
		Name:     codeVerifierCookieName,
		Value:    codeVerifier,
		MaxAge:   int(10 * time.Minute),
		HttpOnly: true,
		Secure:   false, // TODO: set to true for https
		SameSite: http.SameSiteLaxMode,
	})

	http.Redirect(w, r, authUrl.String(), http.StatusFound)
}

We set the query parameters and the scopes. We want the user's profile information and email.

We store the state and codeVerifier in cookies, to use them in the callback step.

If you go to http://localhost:3000/login/google, you will be redirected to Google's authentication flow.

Auth and cryptoutil packages

Move the createState, createCodeVerifier, and createS256CodeChallenge functions to their own package, cryptoutil:

cryptoutil/bytes.go
package cryptoutil

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

func CreateState() (string, error) {
	bytes := make([]byte, 16)
	if _, err := rand.Read(bytes); err != nil {
		return "", err
	}
	return base64.URLEncoding.EncodeToString(bytes), nil
}

func CreateCodeVerifier() (string, error) {
	bytes := make([]byte, 32)
	if _, err := rand.Read(bytes); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(bytes), nil
}

func CreateS256CodeChallenge(codeVerifier string) string {
	hash := sha256.Sum256([]byte(codeVerifier))
	return base64.RawURLEncoding.EncodeToString(hash[:])
}

Create a file called google.go in /auth.

We are going to put the login handler here but as a method in a struct:

auth/google.go
package auth

import (
	"net/http"
	"net/url"
	"time"

    "github.com/mateopresacastro/oauth2/cryptoutil"
)

type google struct {
	clientID               string
	clientSecret           string
	redirectURI            string
	authUrl                string
	tokenUrl               string
	userInfoUrl            string
	stateCookieName        string
	codeVerifierCookieName string
}

func NewGoogle(clientID, clientSecret string) *google {
	return &google{
		clientID:               clientID,
		clientSecret:           clientSecret,
		redirectURI:            "http://localhost:3000/login/google/callback",
		authUrl:                "https://accounts.google.com/o/oauth2/v2/auth",
		tokenUrl:               "https://oauth2.googleapis.com/token",
		userInfoUrl:            "https://www.googleapis.com/oauth2/v2/userinfo",
		stateCookieName:        "google_oauth_state",
		codeVerifierCookieName: "google_code_verifier",
	}
}

func (g *google) HandleLogin(w http.ResponseWriter, r *http.Request) {
	state, err := cryptoutil.CreateState()
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	codeVerifier, err := cryptoutil.CreateCodeVerifier()
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	codeChallenge := cryptoutil.CreateS256CodeChallenge(codeVerifier)

	authUrl, err := url.Parse(g.authUrl)
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	query := authUrl.Query()
	query.Set("response_type", "code")
	query.Set("client_id", g.clientID)
	query.Set("redirect_uri", g.redirectURI)
	query.Set("state", state)
	query.Set("code_challenge_method", "S256")
	query.Set("code_challenge", codeChallenge)
	query.Set("scope", "profile email")

	authUrl.RawQuery = query.Encode()

	http.SetCookie(w, &http.Cookie{
		Name:     g.stateCookieName,
		Value:    state,
		MaxAge:   int(10 * time.Minute),
		HttpOnly: true,
		Secure:   false, // TODO: set to true for https
		SameSite: http.SameSiteLaxMode,
	})

	http.SetCookie(w, &http.Cookie{
		Name:     g.codeVerifierCookieName,
		Value:    codeVerifier,
		MaxAge:   int(10 * time.Minute),
		HttpOnly: true,
		Secure:   false, // TODO: set to true for https
		SameSite: http.SameSiteLaxMode,
	})

	http.Redirect(w, r, authUrl.String(), http.StatusFound)
}

In this way we have access to the google data through the receiver (g *google) in the handlers. And we can initialize a NewGoogle from server.go by passing clientID and clientSecret.

Our server function now looks like this:

server.go
func server() error {
	clientID := os.Getenv("GOOGLE_CLIENT_ID")
	clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
	if clientID == "" || clientSecret == "" {
		return errors.New("google env vars not set")
	}
	port := ":3000"
	google := auth.NewGoogle(clientID, clientSecret)
	mux := http.NewServeMux()
	mux.HandleFunc("GET /login/google", google.HandleLogin)
	// rest ...
}

Verify that it works as before.

Now, write the first version of the callback handler. Google sends back the state we generated, and an authorization code in the query params:

auth/google.go
func (g *google) HandleCallBack(w http.ResponseWriter, r *http.Request) {
	query := r.URL.Query()
	code := query.Get("code")
	state := query.Get("state")
	stateInCookie, err := r.Cookie(g.stateCookieName)
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	codeVerifierInCookie, err := r.Cookie(g.codeVerifierCookieName)
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	if code == "" || state == "" || stateInCookie.Value == "" || codeVerifierInCookie.Value == "" {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	if state != stateInCookie.Value {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

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

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

    // ...
}

We parse the cookies, check that they have values, and that the state of the query params matches the one in the cookies. This makes sure that the callback call was triggered by our auth flow.

After validation, we remove these cookies because they are no longer needed.

Now we need to exchange the code for a token. This will grant us access to the user data.

auth/google.go
    // Inside HandleCallBack
    formValues := url.Values{
        "grant_type":    {"authorization_code"},
        "code":          {authorizationCode},
        "redirect_uri":  {g.redirectURI},
        "code_verifier": {codeVerifierInCookie.Value},
    }

    req, err := http.NewRequest("POST", g.tokenUrl, strings.NewReader(formValues.Encode()))
    if err != nil {
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }

    basicAuth := base64.StdEncoding.EncodeToString([]byte(g.clientID + ":" + g.clientSecret))
    req.Header.Set("Authorization", "Basic "+basicAuth)
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("Accept", "application/json")

    client := &http.Client{Timeout: 10 * time.Second}
    tokenResp, err := client.Do(req)
    if err != nil {
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }
    defer tokenResp.Body.Close()

    if tokenResp.StatusCode != http.StatusOK {
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }

    var tokenRespData struct {
        AccessToken string `json:"access_token"`
    }

    if err := json.NewDecoder(tokenResp.Body).Decode(&tokenRespData); err != nil {
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }

The OAuth 2.0 flow specifies setting up the clientID and clientSecret as Basic Auth in the Authorization header. The Content-Type must be application/x-www-form-urlencoded.

Note that we send the original codeVerifier that we stored in the cookies in the formValues. Google will hash it with SHA256 on their side, and check that it matches the codeChallenge we sent before.

Now, request the user data with the AccessToken:

auth/google.go
    // Continuing in HandleCallBack...

	userReq, err := http.NewRequest("GET", g.userInfoUrl, nil)
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

    // Set the access token in the Authorization header
	userReq.Header.Set("Authorization", "Bearer "+tokenRespData.AccessToken)
	userResp, err := client.Do(userReq)
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer userResp.Body.Close()

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

	if err := json.NewDecoder(userResp.Body).Decode(&userData); err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	slog.Info("Got user data", "data", userData)

	http.Redirect(w, r, "http://localhost:3000", http.StatusPermanentRedirect)

At the very end we redirect to http://localhost:3000. This should redirect to your app.

Go to localhost:3000/login/google in your browser. Login with your Google account. You should see this in your terminal:

terminal
2025/01/05 09:54:34 INFO Listening port=:3000
2025/01/05 09:54:57 INFO Got user data data="{ID:0987520918756 Email:mateopresacastro@gmail.com VerifiedEmail:true Name:Mateo Presa Castro Picture:https://lh3.googleusercontent.com..."

We successfully authenticated our user!

At this points we need to create a new user in our database, create the session, and handle the logic.

Data layer

This section handles data persistence and logging.

Start by defining the models and interface.

Create a new package called store. And the files models.go and main.go:

store/models.go
package store

type User struct {
	ID       int64  `json:"id"`
	GoogleID string `json:"google_id"`
	Email    string `json:"email"`
	Name     string `json:"name"`
	Picture  string `json:"picture"`
}

type Session struct {
	ID        string `json:"id"`
	UserID    int64  `json:"user_id"`
	ExpiresAt int64  `json:"expires_at"`
}
store/main.go
package store

type Store interface {
	// Users
	CreateUser(user *User) (int64, error)
	UserByGoogleID(googleID string) (*User, error)

	// Sessions
	CreateSession(sessionID string, userID int64, expiresAt int64) (*Session, error)
	DeleteSessionBySessionID(sessionID string) (err error)
	SessionAndUserBySessionID(sessionID string) (*Session, *User, error)
	RefreshSession(sessionID string, newExpiresAt int64) error
}

We will satisfy the Store interface with a SQLite implementation.

store/main.go
func New(dbPath string) (Store, error) {
	return newSQLiteStore(dbPath)
}

This way of handling interfaces is not the most idiomatic in Go, but it is fine for this post.

Install the go-sqlite3 driver:

terminal
 go get github.com/mattn/go-sqlite3 && go mod tidy
go: added github.com/mattn/go-sqlite3 v1.14.24
store/sqlite.go
package store

import (
	"database/sql"
	"errors"
	"fmt"
	"log/slog"

	_ "github.com/mattn/go-sqlite3"
)

type sqliteStore struct {
	db *sql.DB
}

func newSQLiteStore(path string) (Store, error) {
	db, err := sql.Open("sqlite3", path)
	if err != nil {
		return nil, fmt.Errorf("error opening database: %w", err)
	}

	if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
		db.Close()
		return nil, fmt.Errorf("error enabling WAL mode: %w", err)
	}

	if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
		db.Close()
		return nil, fmt.Errorf("error enabling foreign keys: %w", err)
	}

	if err := db.Ping(); err != nil {
		db.Close()
		return nil, fmt.Errorf("error connecting to database: %w", err)
	}

	store := &sqliteStore{
		db: db,
	}

	if err := store.initializeTables(); err != nil { // Function shown below
		db.Close()
		return nil, fmt.Errorf("error initializing tables: %w", err)
	}

	return store, nil
}

First we import Go's database/sql package. And go-sqlite3 as a side effect with the _ import.

Then we declare a sqliteStore struct where we save a reference to the DB. We will create the rest of the functions as it's methods.

We enable WAL mode to allow concurrent access.

Now we need to create the tables:

store/sqlite.go
func (s *sqliteStore) initializeTables() error {
	_, err := s.db.Exec(`
        CREATE TABLE IF NOT EXISTS user (
            id INTEGER NOT NULL PRIMARY KEY,
            google_id TEXT NOT NULL UNIQUE,
            email TEXT NOT NULL UNIQUE,
            name TEXT NOT NULL,
            picture TEXT NOT NULL
        )
    `)
	if err != nil {
		return fmt.Errorf("error creating user table: %w", err)
	}

	_, err = s.db.Exec(`
        CREATE INDEX IF NOT EXISTS google_id_index ON user(google_id)
    `)
	if err != nil {
		return fmt.Errorf("error creating google_id index: %w", err)
	}

	_, err = s.db.Exec(`
        CREATE TABLE IF NOT EXISTS session (
            id TEXT NOT NULL PRIMARY KEY,
            user_id INTEGER NOT NULL REFERENCES user(id),
            expires_at INTEGER NOT NULL
        )
    `)
	if err != nil {
		return fmt.Errorf("error creating session table: %w", err)
	}

	return nil
}

This is our CreateUser method that fulfils part of the Store interface:

store/sqlite.go
func (s *sqliteStore) CreateUser(user *User) (int64, error) {
	slog.Info("DB: creating user", "user", user)
	query := `
        INSERT INTO user (google_id, email, name, picture)
        VALUES (?, ?, ?, ?)
    `
	var result sql.Result
	result, err := s.db.Exec(query, user.GoogleID, user.Email, user.Name, user.Picture)
	if err != nil {
		return 0, fmt.Errorf("error creating user: %w", err)
	}

	userID, err := result.LastInsertId()
	if err != nil {
		return 0, fmt.Errorf("error getting last insert id: %w", err)
	}
	slog.Info("DB: user created")
	return userID, nil
}

The other methods are omitted here because they are very similar. You can check the full source here.

The final detail in this section is a custom error that we will use in the callback handler:

store/sqlite.go
var ErrUserNotFound = errors.New("user not found")

Our user is authenticated. We have access to its googleId, picture and other data. Now the system must manage the session lifecycle. The next step is to integrate the parts.

Session manager

Create a new file at session/manager.go

session/manager.go
type Manager struct {
    store                   store.Store
    sessionExpirationInDays int64
    refreshThresholdInDays  int64
    sessionCookieName       string
}

func NewManager(store store.Store, sessionExpirationInDays int64, refreshThresholdInDays int64) *Manager {
	return &Manager{
		store:                   store,
		sessionExpirationInDays: sessionExpirationInDays,
		refreshThresholdInDays:  refreshThresholdInDays,
		sessionCookieName:       "session",
	}
}

In the constructor we pass an instance of the store and the expiry config.

Once Google redirects the user to our callback handler, we have access to its data. We must now create a user record in the database and a new session token, which we will also store in the database and in a cookie.

This token, once again, is a random string we generate. It will uniquely identify the session.

We then hash this token to create the sessionID. And use it as the ID in the database.

If the database is compromised, attackers will see only the hashed values, not the actual tokens from active sessions.

Add these two functions to cryptoutil:

cryptoutil/bytes.go
// This is to generate the token we'll store in the cookie
func Random() (string, error) {
    bytes := make([]byte, 25)
    _, err := rand.Read(bytes)
    if err != nil {
        return "", fmt.Errorf("error generating random bytes: %w", err)
    }
    token := strings.ToLower(base32.StdEncoding.EncodeToString(bytes))
    return token, nil
}

// This is to hash the token and generate the sessionID
func ID(token string) string {
	hash := sha256.Sum256([]byte(token))
	return hex.EncodeToString(hash[:])
}

This function creates a session and saves it in the store:

session/manager.go

func (m *Manager) CreateSession(userID int64) (string, *store.Session, error) {
	token, err := cryptoutil.Random()
	if err != nil {
		return "", nil, err
	}
	sessionID := cryptoutil.ID(token)
	expiresAt := m.newExpiresAt()
	session, err := m.store.CreateSession(sessionID, userID, expiresAt)
	if err != nil {
		return "", nil, fmt.Errorf("error creating session: %w", err)
	}
	return token, session, nil
}

func (m *Manager) newExpiresAt() int64 {
	return time.Now().Add(time.Duration(m.sessionExpirationInDays) * 24 * time.Hour).Unix()
}

Create functions to set and delete the token in the session cookie:

session/manager.go
func (m *Manager) SetSessionCookie(w http.ResponseWriter, token string, expiresAt int64) {
	http.SetCookie(w, &http.Cookie{
		Name:     m.sessionCookieName,
		Value:    token,
		HttpOnly: true,
		Path:     "/",
		Secure:   false, // TODO: set to true for https
		SameSite: http.SameSiteLaxMode,
		Expires:  time.Unix(expiresAt, 0),
	})
}

func (m *Manager) DeleteSessionCookie(w http.ResponseWriter) {
	http.SetCookie(w, &http.Cookie{
		Name:     m.sessionCookieName,
		Value:    "",
		HttpOnly: true,
		Path:     "/",
		Secure:   false, // TODO: set to true for https
		SameSite: http.SameSiteLaxMode,
		MaxAge:   -1,
	})
}

Now we can finish the HandleCallBack in our auth package.

We need to create the user record, the session record, and store the session token in a cookie.

Finishing the callback handler

Update the NewGoogle function and google struct te receive the store and sessionMgr:

auth/google.go
type google struct {
	clientID               string
	clientSecret           string
	redirectURI            string
	authUrl                string
	tokenUrl               string
	userInfoUrl            string
	stateCookieName        string
	codeVerifierCookieName string
	store                  store.Store
	sessionMgr             *session.Manager
}

func NewGoogle(clientID, clientSecret string, store store.Store, sessionMgr *session.Manager) *google {
	return &google{
		clientID:               clientID,
		clientSecret:           clientSecret,
		redirectURI:            "http://localhost:3000/login/google/callback",
		authUrl:                "https://accounts.google.com/o/oauth2/v2/auth",
		tokenUrl:               "https://oauth2.googleapis.com/token",
		userInfoUrl:            "https://www.googleapis.com/oauth2/v2/userinfo",
		stateCookieName:        "google_oauth_state",
		codeVerifierCookieName: "google_code_verifier",
		store:                  store,
		sessionMgr:             sessionMgr,
	}
}

In the callback handler we can now check if a uses exist with the googleID we got earlier. And use the store and sessionManager we just created:

auth/google.go
func (g *google) HandleCallBack(w http.ResponseWriter, r *http.Request) {
	// ... continuing

    // Now we can check if the user already exist by its googleID
    existingUser, err := g.store.UserByGoogleID(userData.ID)
	if err == nil && existingUser != nil { // If user exists
        // Create new session
		token, session, err := g.sessionMgr.CreateSession(existingUser.ID)
		if err != nil {
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		g.sessionMgr.SetSessionCookie(w, token, session.ExpiresAt)
		http.Redirect(w, r, "http://localhost:3000", http.StatusPermanentRedirect)
        return
	}

    // If something went wrong with the database
	if !errors.Is(err, store.ErrUserNotFound) {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

    // Here we know this is a new user. Create one
	user := &store.User{
		Email:    userData.Email,
		Picture:  userData.Picture,
		Name:     userData.Name,
		GoogleID: userData.ID,
	}

	newUserID, err := g.store.CreateUser(user)
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	token, session, err := g.sessionMgr.CreateSession(newUserID)
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	g.sessionMgr.SetSessionCookie(w, token, session.ExpiresAt)
	http.Redirect(w, r, "http://localhost:3000", http.StatusPermanentRedirect)
	return nil
}

Create the store and sessionManager in server.go and pass it to NewGoogle.

server.go
package main

import (
	"errors"
	"log/slog"
	"net/http"
	"os"

	_ "github.com/joho/godotenv/autoload"
	"github.com/mateopresacastro/oauth2/auth"
	"github.com/mateopresacastro/oauth2/session"
	"github.com/mateopresacastro/oauth2/store"
)

const (
	port                    = ":3000"
	sessionExpirationInDays = 30
	refreshThresholdInDays  = 15
)

func server() error {
	clientID := os.Getenv("GOOGLE_CLIENT_ID")
	clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
	if clientID == "" || clientSecret == "" {
		return errors.New("google env vars not set")
	}

	store, err := store.New("./app.db")
	if err != nil {
		return err
	}

	sessionManager := session.NewManager(store, sessionExpirationInDays, refreshThresholdInDays)
	google := auth.NewGoogle(clientID, clientSecret, store, sessionManager)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /login/google", google.HandleLogin)
	mux.HandleFunc("GET /login/google/callback", google.HandleCallBack)
    // Rest of the handlers omitted
	slog.Info("Listening", "port", port)
	return http.ListenAndServe(port, mux)
}

If you go through the auth flow again you should see this:

terminal
2025/01/05 12:16:27 INFO Listening port=:3000
2025/01/05 12:16:40 INFO DB: creating user user="&{ID:0 GoogleID:0987520918756  Email:mateopresacastro@gmail.com Name:Mateo Presa Castro Picture:https://lh3.googleusercontent.com/...}"
2025/01/05 12:16:40 INFO DB: user created
2025/01/05 12:16:40 INFO DB: session created

Check that you have the session cookie in the dev tools: session: ksdzs432mfql3ceb2cxriclunzcptxg67s3mw4jr.

Wrapping up

That is the core of the OAuth 2.0 PKCE flow. You have built a program that can:

  • Send users to Google for authentication.
  • Handle the return, swap the code for a token, and get user data.
  • Create a user in the database.
  • Start a session and save its token in a secure, HttpOnly cookie.

These are the hardest parts of authentication. Adding functions to check or delete a session is simple from here.

This guide used an early version of a small project, which has since grown. For the full source code, which adds session validation, a logout function, middleware, rate-limiting and a frontend example, see the repository on GitHub.

I hope this guide helped you see that authentication is not that hard.