Building Google OAuth with Supabase, SvelteKit and Go — A Complete Guide
A step by step walkthrough of how “Continue with Google” actually works, from browser click to MongoDB user record.

What We Are Building
By the end of this article you will have:
- A Svelte frontend with a “Continue with Google” button
- A Go backend that verifies the identity of logged-in users
- A MongoDB database that stores user records
More importantly, you will understand exactly what happens at every step — not just copy-paste code that works by magic.
Part 1 — The Concepts
Before writing a single line of code, let’s understand the three core concepts this entire system is built on.
What is Authentication?
Authentication is answering one question: “Who are you?”
When you open Gmail, Google needs to verify your identity before showing your inbox. That process is authentication. It is different from authorization, which answers a different question: “What are you allowed to do?”
- Authentication → proving who you are
- Authorization → deciding what you can access
The Core Problem
HTTP — the protocol browsers use to talk to servers — is stateless. Every request is completely independent. The server has no memory of previous requests.
When you load gmail.com/inbox, the server has no idea who you are. It sees an anonymous request. Authentication is the mechanism that attaches identity to every request.
The naive solution would be to send your username and password with every single request. That is obviously terrible. So instead, after you prove your identity once, the server gives you a token — a signed piece of data you attach to future requests.
What is OAuth?
OAuth solves a specific problem: what if you want to prove your identity to App B, but using your identity from App A?
You want to log into Collaborent but you don’t want to create yet another username and password. You already have a Google account. Can Collaborent ask Google “is this person who they say they are?” and trust the answer?
That is exactly what OAuth does. It is a protocol that lets one service (Google) vouch for your identity to another service (Collaborent) — without Collaborent ever seeing your Google password.
The key insight: Collaborent never learns your Google password. Google handles the login, then tells Collaborent “yes, this is [email protected], here is a token proving it.”
What is a JWT?
After verifying your identity, the server needs a way to remember you across future requests without checking your password every time. This is where JWT (JSON Web Token) comes in.
A JWT looks like this:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjMifQ.abc123signature
It has three base64-encoded parts separated by dots:
HEADER . PAYLOAD . SIGNATURE
{ { hmac(header + payload,
"alg": "ES256" "sub": "uuid", privateKey)
} "email": "[email protected]",
"exp": 1234567890
}The payload contains your user ID, email, and when the token expires. The signature is a cryptographic hash that proves the token was not tampered with. If anyone modifies the payload, the signature breaks and the server rejects it.
Your browser stores this JWT and sends it with every API request:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
How Cookies Enable “Continue with Google”
When you click “Continue with Google” on a new app, you often never see a password prompt. This is because you are already logged into Google.
When you logged into Gmail months ago, Google stored a long-lived session cookie in your browser:
HTTP Header from Google's server:
Set-Cookie: session=abc123xyz; Domain=accounts.google.com; HttpOnly
Browsers have one built-in rule:
“Whenever I make a request to a domain, automatically attach all cookies stored for that domain.”
So when “Continue with Google” redirects your browser to accounts.google.com, the browser automatically sends that stored cookie. Google sees it, recognises you, and skips the password prompt.
accounts.google.com — The Single Source of Truth
Gmail lives at gmail.com, YouTube at youtube.com — completely different domains. So how does one Google login cover all of them?
Google uses a central authentication domain: accounts.google.com.
Every Google product — Gmail, YouTube, Docs, Gemini — redirects to accounts.google.com to verify identity. None of them check it themselves.
accounts.google.com cookie → your master Google identity
↓
youtube.com → trusts accounts.google.com
gmail.com → trusts accounts.google.com
docs.google.com → trusts accounts.google.com
Collaborent (via OAuth) → trusts accounts.google.com
This is called Single Sign-On (SSO) — one login gives you access everywhere.
Why Supabase?
You could implement all of this yourself in Go — OAuth redirect handlers, code exchange, JWT issuance, token refresh, session management. That is about a week of careful work with serious security implications if you get it wrong.
Supabase is a service that implements all of it correctly so you don’t have to. The entire OAuth dance becomes one function call:
supabase.auth.signInWithOAuth({ provider: 'google' })Your Go backend only needs to verify the JWT Supabase issues — which is one middleware function.
Part 2 — Setting Up the Infrastructure
Create a Supabase Project
- Go to supabase.com and create an account
- Click New project, name it collaborent
- Choose a region close to you
- Wait for the project to spin up
Set Up Google OAuth Credentials
Go to console.cloud.google.com:
- Create a new project called collaborent
- Go to APIs & Services → OAuth consent screen → Audience → select External
- Fill in your app name and email under Branding
- Go to Clients → Create Client
- Application type: Web application
- Under Authorized redirect URIs paste your Supabase callback URL:
7. Click Create — copy the Client ID and Client Secret
Connect Google to Supabase
In your Supabase dashboard:
- Go to Authentication → Providers → Google
- Enable it
- Paste your Google Client ID and Client Secret
- Save
Part 3 — The Frontend (SvelteKit)
Install Supabase
cd frontend
npm install @supabase/supabase-js
Create the Supabase Client
Create src/lib/supabase.ts:
import { createClient } from '@supabase/supabase-js'export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY
)
Create .env in your frontend folder:
VITE_SUPABASE_URL=https://your-project-id.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
Get both values from Supabase → Settings → API.
Add .env to your .gitignore:
.env
The Login Page
Create src/routes/+page.svelte:
<script lang="ts">
import { onMount } from "svelte";
import { supabase } from "./lib/supabase";
let session: any = null;
let loading = true;
onMount(async () => {
const { data } = await supabase.auth.getSession();
session = data.session;
if (session) {
// Send JWT to Go backend to sync user into MongoDB
const response = await fetch("http://localhost:8080/auth/callback", {
method: "POST",
headers: {
Authorization: `Bearer ${session.access_token}`,
"Content-Type": "application/json",
},
});
const result = await response.json();
console.log("Backend response:", result);
window.location.href = "/dashboard";
}
loading = false;
});
async function signIn() {
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: "http://localhost:5173",
},
});
if (error) console.error(error);
}
</script>
<main>
<h1>Collaborent</h1>
{#if loading}
<p>Loading...</p>
{:else if !session}
<button on:click={signIn}>Continue with Google</button>
{/if}
</main>
The loading flag prevents the button from flashing on screen before onMount finishes checking whether a session already exists.
The Dashboard Page
SvelteKit uses file-based routing — create a folder and file and the route exists automatically:
src/routes/
├── +page.svelte → localhost:5173/
└── dashboard/
└── +page.svelte → localhost:5173/dashboard
Create src/routes/dashboard/+page.svelte:
<script lang="ts">
import { onMount } from "svelte";
import { supabase } from "../lib/supabase";
let user: any = null;
let loading = true;
onMount(async () => {
const { data } = await supabase.auth.getSession();
user = data.session?.user ?? null;
loading = false;
if (!user) {
window.location.href = "/";
}
});
async function signOut() {
await supabase.auth.signOut();
window.location.href = "/";
}
</script>
{#if loading}
<p>Loading...</p>
{:else if user}
<h1>Welcome, {user.user_metadata.full_name}</h1>
<p>{user.email}</p>
<img src={user.user_metadata.avatar_url} alt="avatar" width="50" />
<br />
<button on:click={signOut}>Sign out</button>
{/if}
Part 4 — The Backend (Go)
Install Dependencies
cd backend
go get go.mongodb.org/mongo-driver/v2/mongo
go get go.mongodb.org/mongo-driver/v2/mongo/options
go get go.mongodb.org/mongo-driver/v2/bson
go get github.com/golang-jwt/jwt/v5
go get github.com/MicahParks/keyfunc/v3
go get github.com/joho/godotenv
Create the Backend .env
MONGODB_URI=mongodb+srv://username:[email protected]/
MONGODB_DB=collaborent
SUPABASE_URL=https://your-project-id.supabase.co
The Complete main.go
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
"github.com/joho/godotenv"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
type User struct {
Email string `bson:"email"`
Name string `bson:"name"`
Avatar string `bson:"avatar"`
SupabaseID string `bson:"supabaseId"`
CreatedAt time.Time `bson:"createdAt"`
LastLoginAt time.Time `bson:"lastLoginAt"`
}
var userCollection *mongo.Collection
var jwks keyfunc.Keyfunc
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func authCallbackHandler(w http.ResponseWriter, r *http.Request) {
// 1. Get JWT from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing token", http.StatusUnauthorized)
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
// 2. Verify JWT using Supabase public keys
token, err := jwt.Parse(tokenStr, jwks.Keyfunc)
if err != nil || !token.Valid {
log.Println("JWT error:", err)
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
// 3. Extract user info from JWT payload
claims := token.Claims.(jwt.MapClaims)
supabaseID := claims["sub"].(string)
email, _ := claims["email"].(string)
metadata, _ := claims["user_metadata"].(map[string]interface{})
name, _ := metadata["full_name"].(string)
avatar, _ := metadata["avatar_url"].(string)
// 4. Upsert user into MongoDB
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
filter := bson.M{"supabaseId": supabaseID}
update := bson.M{
"$set": bson.M{
"email": email,
"name": name,
"avatar": avatar,
"lastLoginAt": time.Now(),
},
"$setOnInsert": bson.M{
"supabaseId": supabaseID,
"createdAt": time.Now(),
},
}
opts := options.UpdateOne().SetUpsert(true)
_, err = userCollection.UpdateOne(ctx, filter, update, opts)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// 5. Return success
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"email": email,
"name": name,
})
}
func main() {
if err := godotenv.Load(); err != nil {
log.Println("No .env file found")
}
// Fetch Supabase public keys for JWT verification
jwksURL := fmt.Sprintf("%s/auth/v1/.well-known/jwks.json", os.Getenv("SUPABASE_URL"))
var err error
jwks, err = keyfunc.NewDefault([]string{jwksURL})
if err != nil {
log.Fatal("Failed to fetch JWKS:", err)
}
log.Println("JWKS loaded from Supabase")
// Connect to MongoDB
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := mongo.Connect(options.Client().ApplyURI(os.Getenv("MONGODB_URI")))
if err != nil {
log.Fatal("MongoDB connection failed:", err)
}
defer client.Disconnect(ctx)
if err := client.Ping(ctx, nil); err != nil {
log.Fatal("MongoDB ping failed:", err)
}
log.Println("Connected to MongoDB")
userCollection = client.Database(os.Getenv("MONGODB_DB")).Collection("users")
http.HandleFunc("/health", corsMiddleware(healthHandler))
http.HandleFunc("/auth/callback", corsMiddleware(authCallbackHandler))
log.Println("Backend running on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Part 5 — The Complete Data Flow
This is what actually happens from the moment you click “Continue with Google” to the moment your user record appears in MongoDB.
What You See in the URL Bar
localhost:5173 ← you are here, click the button
accounts.google.com ← Google verifies your identity
localhost:5173 ← back to your app (invisible steps happen here)
localhost:5173/dashboard ← you are logged in
Only two URLs are visible. Everything else happens invisibly — either as server-to-server calls your browser never sees, or as millisecond-fast redirects too quick to read.
The Full Step by Step Flow
Step 1 — You click “Continue with Google”
supabase.auth.signInWithOAuth({ provider: "google" })The Supabase JS library builds a redirect URL and navigates your browser to:
https://accounts.google.com/oauth/authorize
?client_id=your-google-client-id
&redirect_uri=https://your-project.supabase.co/auth/v1/callback
&scope=email profile
&response_type=code
Step 2 — Google verifies your identity
Your browser lands on accounts.google.com. The browser checks its cookie jar and finds the session cookie stored from when you last logged into Gmail. It automatically attaches the cookie to the request.
Google sees the cookie, recognises you as [email protected], and skips the password prompt. It shows you a consent screen: “Do you want to give Collaborent access to your email and profile?”
You click Allow.
Step 3 — Google redirects to Supabase with a code
Google sends your browser to the Supabase callback URL with a short-lived authorisation code:
https://your-project.supabase.co/auth/v1/callback?code=abc123xyz
This code is not a token — it is a one-time-use key that only Supabase can exchange for real user data.
Step 4 — Supabase exchanges the code (server to server)
This step is invisible to your browser. Supabase’s servers talk directly to Google’s servers:
Supabase → Google: "Here is the code abc123xyz, give me the user info"
Google → Supabase: "Valid. The user is [email protected], name: Akshat, avatar: https://..."
Your browser URL bar does not change during this step. It happens entirely between Supabase’s servers and Google’s servers.
Step 5 — Supabase issues a JWT
Supabase:
- Creates or finds the user in its own internal database
- Generates a JWT signed with its ECC private key
- Stores the JWT in the Supabase JS client’s session storage in your browser
- Redirects your browser to http://localhost:5173
The JWT payload looks like:
{
"sub": "abc-123-uuid",
"email": "[email protected]",
"user_metadata": {
"full_name": "Akshat",
"avatar_url": "https://lh3.googleusercontent.com/..."
},
"exp": 1234567890,
"iss": "https://your-project.supabase.co/auth/v1"
}Step 6 — Svelte detects the session
Your browser lands on localhost:5173. The onMount function runs automatically:
const { data } = await supabase.auth.getSession()
const session = data.sessionThe Supabase JS library reads the JWT from session storage and returns it. session.access_token now contains the full JWT string.
Step 7 — Svelte sends the JWT to your Go backend
await fetch("http://localhost:8080/auth/callback", {
method: "POST",
headers: {
Authorization: `Bearer ${session.access_token}`
}
})Your browser sends an HTTP POST request to your Go server with the JWT in the Authorization header.
Step 8 — Go fetches Supabase’s public keys
On startup, your Go server fetched Supabase’s public keys from:
https://your-project.supabase.co/auth/v1/.well-known/jwks.json
This is a public URL — anyone can read it. It contains the public key that corresponds to Supabase’s private signing key. Your Go server cached these keys using the keyfunc library.
Step 9 — Go verifies the JWT
token, err := jwt.Parse(tokenStr, jwks.Keyfunc)
Go uses the cached public key to verify the JWT signature. Remember:
- Supabase signed the JWT with its private key (only Supabase has this)
- Your Go server verifies it with the public key (safe to share)
This is asymmetric cryptography. You cannot fake a JWT signed by Supabase’s private key, even if you know the public key. The math makes it impossible.
Go also automatically checks:
- Is the token expired?
- Was it issued by the correct Supabase project?
Step 10 — Go extracts user data from the JWT payload
claims := token.Claims.(jwt.MapClaims)
supabaseID := claims["sub"].(string)
email, _ := claims["email"].(string)
metadata, _ := claims["user_metadata"].(map[string]interface{})
name, _ := metadata["full_name"].(string)
avatar, _ := metadata["avatar_url"].(string)
No extra API call to Supabase needed. The user’s email, name, and avatar were already inside the JWT payload.
Step 11 — Go upserts the user into MongoDB
filter := bson.M{"supabaseId": supabaseID}
update := bson.M{
"$set": bson.M{
"email": email,
"name": name,
"avatar": avatar,
"lastLoginAt": time.Now(),
},
"$setOnInsert": bson.M{
"supabaseId": supabaseID,
"createdAt": time.Now(),
},
}
userCollection.UpdateOne(ctx, filter, update, opts)MongoDB receives the query:
- New user → create document, set all fields including createdAt
- Returning user → update email, name, avatar, lastLoginAt only
createdAt is protected by $setOnInsert — it only runs when creating a new document, never on updates.
Step 12 — Go returns success, Svelte redirects to dashboard
Go sends back:
{ "status": "success", "email": "[email protected]", "name": "Akshat" }Svelte receives this and redirects to /dashboard, which reads the session and displays the user's name, email, and profile picture.
Part 6 — Key Concepts Summarised
Why not redirect directly to the Go backend after OAuth?
The JWT that Supabase issues lives in the browser — specifically in the Supabase JS client. Your Go backend has no Supabase JS client and cannot read the JWT on its own. So the frontend must receive the redirect first, read the JWT, and then manually pass it to the backend via the Authorization header.
Why use JWKS instead of a shared secret?
Supabase now uses ECC (P-256) asymmetric signing by default. Instead of both sides sharing one secret key, Supabase signs with a private key and you verify with a public key. Your Go backend never needs to know the private key — making it far more secure. The public key is fetched automatically from Supabase’s JWKS endpoint.
What is CORS and why did we need it?
CORS (Cross-Origin Resource Sharing) is a browser security rule. Your Svelte app on port 5173 was trying to call your Go server on port 8080 — different ports means different origins, and the browser blocks such requests by default. Adding CORS headers to your Go server explicitly tells the browser “requests from port 5173 are allowed.”
What is an upsert?
An upsert is a MongoDB operation that means “update if exists, insert if not.” Instead of writing separate logic for new vs returning users, one UpdateOne call with SetUpsert(true) handles both cases.
The Complete Picture
Browser
└── Click "Continue with Google"
↓
accounts.google.com
└── Cookie found → skip password → consent screen → Allow
↓
Supabase (server to server, invisible)
└── Exchange code with Google → get user info → issue JWT
↓
Browser (localhost:5173)
└── onMount → read JWT from session → POST to Go backend
↓
Go Backend (localhost:8080)
└── Verify JWT signature using Supabase public key
└── Extract email, name, avatar from JWT payload
└── Upsert user document in MongoDB
└── Return success
↓
Browser
└── Redirect to /dashboard → show user info
Every piece serves a specific purpose. Google verifies identity. Supabase manages the OAuth dance and issues tokens. The JWT carries user data securely between services. Go verifies the token mathematically without calling Supabase on every request. MongoDB stores the permanent user record.
Built while developing Collaborent — a real-time collaborative document editor built with SvelteKit, Go, and MongoDB.
Connect with me : Linkedin
Building Google OAuth with Supabase, SvelteKit and Go was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.