OAuth 2.0 PKCE Flow in Go
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:
/login/google
to redirect the user to Google./login/google/callback
to get the callback from Google./login/session
to get check the state of the session./logout
to let the user logout and delete the session.
To start, set up the project:
go mod init github.com/yourusername/oauth2 && air init
Create the server:
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
:
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:
➜ air
2025/01/04 16:59:44 INFO Listening port=:3000
➜ curl localhost:3000/login/google
Login%
➜ curl -X POST localhost:3000/logout
Logout%
Set the environment variables:
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
Install godotenv
and import it in your server:
➜ go get github.com/joho/godotenv
go: added github.com/joho/godotenv v1.5.1
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
:
- Our
clientID
. - The
redirectURI
of our server (callbackURL
). - A token called
state
. - A hash of
codeVerifier
(another token), calledcodeChallenge
.
We must generate the state
and codeVerifier
ourselves:
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:
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:
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
:
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
:
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:
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:
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.
// 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
:
// 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:
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
:
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"`
}
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.
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:
➜ go get github.com/mattn/go-sqlite3 && go mod tidy
go: added github.com/mattn/go-sqlite3 v1.14.24
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:
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:
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:
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
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
:
// 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
:
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
:
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
:
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:
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
.
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:
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.