Start now →

Building Google OAuth with Supabase, SvelteKit and Go

By Akshat Tiwari · Published March 26, 2026 · 14 min read · Source: Level Up Coding
Blockchain
Building Google OAuth with Supabase, SvelteKit and Go

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:

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?”

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

  1. Go to supabase.com and create an account
  2. Click New project, name it collaborent
  3. Choose a region close to you
  4. Wait for the project to spin up

Set Up Google OAuth Credentials

Go to console.cloud.google.com:

  1. Create a new project called collaborent
  2. Go to APIs & Services → OAuth consent screen → Audience → select External
  3. Fill in your app name and email under Branding
  4. Go to Clients → Create Client
  5. Application type: Web application
  6. 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:

  1. Go to Authentication → Providers → Google
  2. Enable it
  3. Paste your Google Client ID and Client Secret
  4. 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:

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.session

The 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:

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:

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:

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.

This article was originally published on Level Up Coding and is republished here under RSS syndication for informational purposes. All rights and intellectual property remain with the original author. If you are the author and wish to have this article removed, please contact us at [email protected].

NexaPay — Accept Card Payments, Receive Crypto

No KYC · Instant Settlement · Visa, Mastercard, Apple Pay, Google Pay

Get Started →