Backend Frontend Security 16 min read

Firebase Authentication with Go and React: Google, GitHub, and Email/Password

Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Apr 6, 2026

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 OAuthFirebase Auth
Adding a providerNew backend routes + secretsEnable in console + one line frontend
Mobile supportSeparate redirect flowSame getIdToken() call
Email/passwordBuild it yourselfBuilt-in
Backend complexityHighLow — 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_JSON in single quotes — the JSON contains " characters that break .env parsing 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

  1. Create project at console.firebase.google.com
  2. 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)
  3. Project settings → Service accounts → Generate new private key — downloads the JSON for FIREBASE_CREDENTIALS_JSON
  4. 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.

go golang react typescript firebase jwt authentication fiber postgresql
Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Full-stack developer with 8+ years experience. Building scalable systems with Go, TypeScript, and React.