This guide walks through replacing a backend-handled Google OAuth flow with Firebase Authentication — supporting Google, GitHub, and email/password from a single endpoint, while keeping the same JWT + HTTP-only refresh token strategy on the backend.
Source: github.com/phathdt/login-oauth
Why Firebase Auth?
The previous approach (covered in Google OAuth Login with Go and React) handled OAuth entirely on the backend: redirect → code exchange → userinfo fetch. It works, but adding a second provider means duplicating the whole flow.
Firebase Auth moves provider complexity to the client:
| Backend OAuth | Firebase Auth | |
|---|---|---|
| Adding a provider | New backend routes + secrets | Enable in console + one line frontend |
| Mobile support | Separate redirect flow | Same getIdToken() call |
| Email/password | Build it yourself | Built-in |
| Backend complexity | High | Low — one POST /auth/firebase |
The backend’s job shrinks to: verify Firebase ID token → upsert user → issue JWT.
Architecture
sequenceDiagram
participant Browser
participant React
participant Firebase
participant GoAPI
participant Postgres
React->>Firebase: signInWithPopup(provider)
Firebase-->>React: FirebaseUser + ID token (1h)
React->>GoAPI: POST /auth/firebase {id_token}
GoAPI->>Firebase: VerifyIDToken(id_token)
Firebase-->>GoAPI: {uid, email, name, picture, provider}
GoAPI->>Postgres: FindOrCreate user (firebase_uid)
GoAPI->>GoAPI: Generate JWT (15min) + Refresh token (7d)
GoAPI->>Postgres: Store hashed refresh token
GoAPI-->>React: {access_token, user} + HTTP-only cookie
React->>React: Store access_token in memory
React-->>Browser: Navigate to /products
Token strategy (unchanged from previous post):
- Access token: JWT (15 min), in-memory only
- Refresh token: SHA-256 hashed, HTTP-only cookie (7 days)
Database Schema
One new column (firebase_uid replaces google_id) and a provider column to track sign-in method:
-- 20260406000000_init.sql
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(255) UNIQUE NOT NULL,
provider VARCHAR(50) NOT NULL DEFAULT 'google',
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255),
picture VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW()
);
firebase_uid is Firebase’s internal UID — it’s the same regardless of which provider the user signs in with, so it works as a stable identifier. provider records the last-used sign-in method ('google', 'github', 'email').
Future providers (Apple, Microsoft) just need an enabled toggle in Firebase Console and one new case in the backend switch — no schema changes.
Backend: Go + Fiber
Project structure
api/
├── cmd/server/main.go
└── internal/
├── auth/
│ ├── firebase.go # Firebase Admin SDK init + token verification
│ ├── jwt.go # JWT generation/validation
│ └── middleware.go # Bearer auth middleware
├── config/config.go
├── database/
│ └── migrations/
│ └── 20260406000000_init.sql
├── db/ # SQLC-generated queries
├── handlers/
│ └── auth_handler.go # FirebaseLogin + Refresh + Logout + Me
└── models/
└── refresh_token.go
Firebase Admin SDK
// internal/auth/firebase.go
type FirebaseClient struct {
client *firebaseauth.Client
}
type FirebaseTokenInfo struct {
UID string
Provider string // 'google', 'github', 'email', ...
Email string
Name string
Picture string
}
func NewFirebaseClient(cfg *config.Config) (*FirebaseClient, error) {
ctx := context.Background()
var opts []option.ClientOption
if cfg.FirebaseCredentialsJSON != "" {
opts = append(opts, option.WithCredentialsJSON([]byte(cfg.FirebaseCredentialsJSON)))
}
app, err := firebase.NewApp(ctx, &firebase.Config{
ProjectID: cfg.FirebaseProjectID,
}, opts...)
if err != nil {
return nil, fmt.Errorf("init firebase app: %w", err)
}
client, err := app.Auth(ctx)
if err != nil {
return nil, fmt.Errorf("get firebase auth client: %w", err)
}
return &FirebaseClient{client: client}, nil
}
Token verification extracts claims and maps Firebase’s provider strings to short names:
func (f *FirebaseClient) VerifyIDToken(ctx context.Context, idToken string) (*FirebaseTokenInfo, error) {
token, err := f.client.VerifyIDToken(ctx, idToken)
if err != nil {
return nil, fmt.Errorf("verify firebase id token: %w", err)
}
info := &FirebaseTokenInfo{
UID: token.UID,
Provider: extractProvider(token),
}
if v, ok := token.Claims["email"].(string); ok { info.Email = v }
if v, ok := token.Claims["name"].(string); ok { info.Name = v }
if v, ok := token.Claims["picture"].(string); ok { info.Picture = v }
return info, nil
}
func extractProvider(token *firebaseauth.Token) string {
if fb, ok := token.Claims["firebase"].(map[string]interface{}); ok {
if p, ok := fb["sign_in_provider"].(string); ok {
switch p {
case "google.com": return "google"
case "github.com": return "github"
case "microsoft.com": return "microsoft"
case "password": return "email"
default: return p
}
}
}
return "unknown"
}
The /auth/firebase endpoint
This single handler replaces the old /auth/google/login + /auth/google/callback pair:
// internal/handlers/auth_handler.go
type firebaseLoginRequest struct {
IDToken string `json:"id_token"`
}
func (h *AuthHandler) FirebaseLogin(c *fiber.Ctx) error {
var req firebaseLoginRequest
if err := c.BodyParser(&req); err != nil || req.IDToken == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id_token is required"})
}
tokenInfo, err := h.firebaseClient.VerifyIDToken(c.Context(), req.IDToken)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid firebase token"})
}
user, err := h.queries.FindOrCreateUser(context.Background(), dbpkg.FindOrCreateUserParams{
FirebaseUID: tokenInfo.UID,
Provider: tokenInfo.Provider,
Email: tokenInfo.Email,
Name: sql.NullString{String: tokenInfo.Name, Valid: tokenInfo.Name != ""},
Picture: sql.NullString{String: tokenInfo.Picture, Valid: tokenInfo.Picture != ""},
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to upsert user"})
}
accessToken, err := auth.GenerateAccessToken(h.cfg, user)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate access token"})
}
expiresAt := time.Now().Add(7 * 24 * time.Hour)
refreshToken, err := models.CreateRefreshToken(h.queries, user.ID.String(), expiresAt)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create refresh token"})
}
c.Cookie(&fiber.Cookie{
Name: "refresh_token",
Value: refreshToken,
HTTPOnly: true,
SameSite: "Lax",
Secure: h.cfg.Env == "production",
MaxAge: 7 * 24 * 3600,
Path: "/",
})
return c.JSON(fiber.Map{
"access_token": accessToken,
"user": fiber.Map{
"id": user.ID.String(),
"email": user.Email,
"name": user.Name.String,
"picture": user.Picture.String,
},
})
}
Routes
// cmd/server/main.go
app.Post("/auth/firebase", authHandler.FirebaseLogin)
app.Post("/auth/refresh", authHandler.Refresh)
app.Post("/auth/logout", authHandler.Logout)
protected := app.Group("/", auth.JWTAuth(cfg))
protected.Get("/auth/me", authHandler.Me)
protected.Get("/api/products", productHandler.List)
Config
type Config struct {
Port string
Env string
DatabaseURL string
JWTSecret string
FrontendURL string
FirebaseProjectID string
FirebaseCredentialsJSON string // full service account JSON string
}
# api/.env
FIREBASE_PROJECT_ID=your-project-id
FIREBASE_CREDENTIALS_JSON='{"type":"service_account","project_id":"...","private_key":"-----BEGIN PRIVATE KEY-----\n...","client_email":"..."}'
Wrap
FIREBASE_CREDENTIALS_JSONin single quotes — the JSON contains"characters that break.envparsing without quoting.
Frontend: React + TypeScript
Firebase config
// src/lib/firebase.ts
import { initializeApp } from 'firebase/app'
import {
getAuth,
GoogleAuthProvider, GithubAuthProvider,
signInWithPopup,
signInWithEmailAndPassword,
createUserWithEmailAndPassword,
} from 'firebase/auth'
const app = initializeApp({
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
})
export const firebaseAuth = getAuth(app)
const googleProvider = new GoogleAuthProvider()
const githubProvider = new GithubAuthProvider()
async function signInWithProvider(provider: GoogleAuthProvider | GithubAuthProvider) {
const result = await signInWithPopup(firebaseAuth, provider)
return result.user.getIdToken()
}
export const signInWithGoogle = () => signInWithProvider(googleProvider)
export const signInWithGithub = () => signInWithProvider(githubProvider)
export async function signInWithEmail(email: string, password: string) {
const result = await signInWithEmailAndPassword(firebaseAuth, email, password)
return result.user.getIdToken()
}
export async function signUpWithEmail(email: string, password: string) {
const result = await createUserWithEmailAndPassword(firebaseAuth, email, password)
return result.user.getIdToken()
}
# web/.env
VITE_FIREBASE_API_KEY=AIza...
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project-id
Auth context
The context exposes a single loginWithFirebase(idToken) method — the same call regardless of which provider was used:
// src/contexts/auth-context.tsx (key addition)
const loginWithFirebase = useCallback(async (idToken: string) => {
const res = await apiClient.post<{ access_token: string; user: User }>('/auth/firebase', {
id_token: idToken,
})
setTokens(res.data.access_token, res.data.user)
}, [setTokens])
Login page
// src/routes/login.tsx
type Mode = 'login' | 'register'
export default function LoginPage() {
const { loginWithFirebase } = useAuth()
const navigate = useNavigate()
const [mode, setMode] = useState<Mode>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState<'google' | 'github' | 'email' | null>(null)
async function handleSocialLogin(provider: 'google' | 'github') {
setPending(provider)
try {
const idToken = provider === 'google' ? await signInWithGoogle() : await signInWithGithub()
await loginWithFirebase(idToken)
navigate('/products', { replace: true })
} catch {
setError('Sign in failed. Please try again.')
} finally {
setPending(null)
}
}
async function handleEmailSubmit(e: React.FormEvent) {
e.preventDefault()
setPending('email')
try {
const idToken = mode === 'login'
? await signInWithEmail(email, password)
: await signUpWithEmail(email, password)
await loginWithFirebase(idToken)
navigate('/products', { replace: true })
} catch (err: unknown) {
// map Firebase error codes to friendly messages
const msg = err instanceof Error ? err.message : ''
if (msg.includes('invalid-credential')) setError('Invalid email or password.')
else if (msg.includes('email-already-in-use')) setError('Email already in use.')
else if (msg.includes('weak-password')) setError('Password must be at least 6 characters.')
else setError('Authentication failed.')
} finally {
setPending(null)
}
}
// ... render form with email/password + Google + GitHub buttons
}
Firebase Console Setup
- Create project at console.firebase.google.com
- Authentication → Sign-in method → enable:
- Email/Password
- Google (set support email)
- GitHub (requires a GitHub OAuth App — set callback URL to
https://your-project.firebaseapp.com/__/auth/handler)
- Project settings → Service accounts → Generate new private key — downloads the JSON for
FIREBASE_CREDENTIALS_JSON - Project settings → General → Your apps → Web — register app to get
VITE_FIREBASE_*values
Adding More Providers Later
Firebase Auth makes adding providers a two-step change:
Backend — one new case in extractProvider():
case "apple.com": return "apple"
Frontend — one new function in firebase.ts:
import { OAuthProvider } from 'firebase/auth'
const appleProvider = new OAuthProvider('apple.com')
export const signInWithApple = () => signInWithProvider(appleProvider)
No new routes, no new DB columns, no new backend handlers.
Blocking Users
Because Firebase only handles identity (“who are you?”), your backend controls access (“are you allowed in?”). Blocking is a one-migration + one-check away:
-- future migration
ALTER TABLE users ADD COLUMN blocked_at TIMESTAMP;
// in FirebaseLogin, after FindOrCreateUser
if user.BlockedAt.Valid {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "account suspended"})
}
A blocked user can still authenticate with Firebase — they just can’t get a backend JWT, so they can’t access any protected API routes.
Trade-offs
Pros:
- One backend endpoint for all providers, all platforms (web, iOS, Android)
- Adding providers requires almost no backend changes
- Email/password, magic links, phone auth available with zero extra backend work
Cons:
- Firebase is a hard dependency — outage = login is down
- Extra round-trip: client → Firebase → client → your backend
- Service account JSON is a high-value secret
- Two token lifecycles (Firebase ID token + your JWT) to reason about
- Vendor lock-in — migrating away from Firebase is painful
For multi-platform products with multiple providers, the trade-off strongly favours Firebase. For a single-platform web app with only Google login, backend OAuth is simpler.