Backend Frontend Security 18 min read

Google OAuth Login with Go and React: JWT, Refresh Tokens, and HTTP-Only Cookies

Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Apr 6, 2026

This guide walks through a full-stack Google OAuth 2.0 implementation — Go + Fiber backend, React + TypeScript frontend — covering every production concern: CSRF state validation, JWT access tokens stored only in memory, hashed refresh tokens in HTTP-only cookies, and automatic silent refresh with request queuing.

Source: github.com/phathdt/login-oauth

Architecture Overview

sequenceDiagram
    participant Browser
    participant React
    participant GoAPI
    participant Google
    participant Postgres

    React->>GoAPI: GET /auth/google/login
    GoAPI->>GoAPI: Generate state (CSRF)
    GoAPI-->>Browser: Redirect to Google OAuth URL

    Browser->>Google: User grants consent
    Google-->>Browser: Redirect /auth/google/callback?code=...&state=...

    Browser->>GoAPI: GET /auth/google/callback
    GoAPI->>GoAPI: Validate state
    GoAPI->>Google: Exchange code → access token
    Google-->>GoAPI: OAuth access token
    GoAPI->>Google: GET /userinfo
    Google-->>GoAPI: {sub, email, name, picture}
    GoAPI->>Postgres: FindOrCreate user
    GoAPI->>GoAPI: Generate JWT (15min) + Refresh token (7d)
    GoAPI->>Postgres: Store hashed refresh token
    GoAPI-->>Browser: Set HTTP-only cookie + Redirect /auth/callback?access_token=JWT

    Browser->>React: /auth/callback page loads
    React->>GoAPI: GET /auth/me (Bearer JWT)
    GoAPI-->>React: {user, fresh_access_token}
    React->>React: Store token in memory
    React-->>Browser: Navigate to /products

Token strategy:

  • Access token: JWT (15 min), stored in React memory only — never in localStorage
  • Refresh token: 32-byte random, SHA-256 hashed before DB storage, sent as HTTP-only cookie

Backend: Go + Fiber

Project structure

api/
├── cmd/server/main.go
└── internal/
    ├── auth/
    │   ├── oauth.go        # OAuth config + CSRF state
    │   ├── jwt.go          # Token generation/validation
    │   └── middleware.go   # Bearer auth middleware
    ├── config/config.go
    ├── database/
    │   └── migrations/
    ├── db/                 # SQLC-generated queries
    ├── handlers/
    │   ├── oauth_handler.go
    │   └── auth_handler.go
    └── models/
        └── refresh_token.go

Database schema

Two tables — users (upserted on OAuth login) and refresh tokens (hashed, revocable):

-- +goose Up
CREATE TABLE IF NOT EXISTS users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  google_id VARCHAR(255) UNIQUE NOT NULL,
  email VARCHAR(255) UNIQUE NOT NULL,
  name VARCHAR(255),
  picture VARCHAR(500),
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS refresh_tokens (
  id SERIAL PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES users(id),
  token_hash CHAR(64) NOT NULL UNIQUE,
  expires_at TIMESTAMP NOT NULL,
  revoked_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW()
);

token_hash is CHAR(64) — the exact output size of SHA-256 as hex. Storing the hash means a leaked DB dump cannot be replayed.

CSRF state management

// internal/auth/oauth.go
type stateEntry struct {
    expiresAt time.Time
}

var stateStore sync.Map

func NewOAuthConfig(cfg *config.Config) *oauth2.Config {
    return &oauth2.Config{
        ClientID:     cfg.GoogleClientID,
        ClientSecret: cfg.GoogleClientSecret,
        RedirectURL:  "http://localhost:3000/auth/google/callback",
        Scopes:       []string{"openid", "email", "profile"},
        Endpoint:     google.Endpoint,
    }
}

func GenerateState() (string, error) {
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil {
        return "", fmt.Errorf("failed to generate state: %w", err)
    }
    state := base64.URLEncoding.EncodeToString(b)
    stateStore.Store(state, stateEntry{expiresAt: time.Now().Add(10 * time.Minute)})
    return state, nil
}

func ValidateState(state string) bool {
    val, ok := stateStore.LoadAndDelete(state)
    if !ok {
        return false
    }
    entry := val.(stateEntry)
    return time.Now().Before(entry.expiresAt)
}

LoadAndDelete is key — it removes the state on first use, so replay attacks fail. States also expire after 10 minutes.

OAuth handler

// internal/handlers/oauth_handler.go

func (h *OAuthHandler) Login(c *fiber.Ctx) error {
    state, err := auth.GenerateState()
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate state"})
    }
    url := h.oauthConfig.AuthCodeURL(state)
    return c.Redirect(url, fiber.StatusTemporaryRedirect)
}

func (h *OAuthHandler) Callback(c *fiber.Ctx) error {
    state := c.Query("state")
    if !auth.ValidateState(state) {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid state"})
    }

    code := c.Query("code")
    token, err := h.oauthConfig.Exchange(context.Background(), code)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to exchange code"})
    }

    userInfo, err := fetchGoogleUserInfo(token.AccessToken)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch user info"})
    }

    user, err := h.queries.FindOrCreateUser(context.Background(), dbpkg.FindOrCreateUserParams{
        GoogleID: userInfo.Sub,
        Email:    userInfo.Email,
        Name:     sql.NullString{String: userInfo.Name, Valid: userInfo.Name != ""},
        Picture:  sql.NullString{String: userInfo.Picture, Valid: userInfo.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:     "/",
    })

    redirectURL := fmt.Sprintf("%s/auth/callback?access_token=%s", h.cfg.FrontendURL, accessToken)
    return c.Redirect(redirectURL, fiber.StatusTemporaryRedirect)
}

The callback does five things in sequence: validate state → exchange code → fetch user info → upsert user → issue tokens. The JWT goes in the redirect URL query string (short-lived, 15 min). The refresh token goes in an HTTP-only cookie.

Fetching Google user info

type googleUserInfo struct {
    Sub     string `json:"sub"`
    Email   string `json:"email"`
    Name    string `json:"name"`
    Picture string `json:"picture"`
}

func fetchGoogleUserInfo(accessToken string) (*googleUserInfo, error) {
    req, _ := http.NewRequest("GET", "https://openidconnect.googleapis.com/v1/userinfo", nil)
    req.Header.Set("Authorization", "Bearer "+accessToken)

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    var info googleUserInfo
    if err := json.Unmarshal(body, &info); err != nil {
        return nil, err
    }
    return &info, nil
}

Sub is Google’s stable user ID — use this as the unique key, not email, which can change.

JWT generation and validation

// internal/auth/jwt.go
type Claims struct {
    UserID  string `json:"sub"`
    Email   string `json:"email"`
    Name    string `json:"name"`
    Picture string `json:"picture"`
    jwt.RegisteredClaims
}

func GenerateAccessToken(cfg *config.Config, user dbpkg.User) (string, error) {
    claims := Claims{
        UserID:  user.ID.String(),
        Email:   user.Email,
        Name:    user.Name.String,
        Picture: user.Picture.String,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(cfg.JWTSecret))
}

func ValidateToken(cfg *config.Config, tokenStr string) (*Claims, error) {
    claims := &Claims{}
    token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
        }
        return []byte(cfg.JWTSecret), nil
    })
    if err != nil || !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }
    return claims, nil
}

The signing method check is important — it prevents the alg: none attack where an attacker strips the signature.

Refresh token model

// internal/models/refresh_token.go
func hashToken(plainToken string) string {
    sum := sha256.Sum256([]byte(plainToken))
    return hex.EncodeToString(sum[:])
}

func CreateRefreshToken(q *dbpkg.Queries, userID string, expiresAt time.Time) (string, error) {
    b := make([]byte, 32)
    rand.Read(b)
    plainToken := base64.URLEncoding.EncodeToString(b)
    tokenHash := hashToken(plainToken)

    uid, _ := uuid.Parse(userID)
    q.CreateRefreshToken(context.Background(), dbpkg.CreateRefreshTokenParams{
        UserID:    uid,
        TokenHash: tokenHash,
        ExpiresAt: expiresAt,
    })
    return plainToken, nil  // plain token sent to client; hash stored in DB
}

func ValidateRefreshToken(q *dbpkg.Queries, plainToken string) (string, error) {
    tokenHash := hashToken(plainToken)
    rt, err := q.FindRefreshToken(context.Background(), tokenHash)
    if err != nil {
        return "", fmt.Errorf("refresh token not found or expired")
    }
    return rt.UserID.String(), nil
}

func RevokeRefreshToken(q *dbpkg.Queries, plainToken string) error {
    tokenHash := hashToken(plainToken)
    return q.RevokeRefreshToken(context.Background(), tokenHash)
}

The plain token goes to the client. The DB only ever sees the SHA-256 hash. Revocation sets revoked_at, and the SQL query filters both expired and revoked tokens.

Auth middleware

// internal/auth/middleware.go
func JWTAuth(cfg *config.Config) fiber.Handler {
    return func(c *fiber.Ctx) error {
        header := c.Get("Authorization")
        if !strings.HasPrefix(header, "Bearer ") {
            return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
        }
        tokenStr := strings.TrimPrefix(header, "Bearer ")
        claims, err := ValidateToken(cfg, tokenStr)
        if err != nil {
            return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token"})
        }
        c.Locals("claims", claims)
        return c.Next()
    }
}

Claims are attached to the request context via c.Locals — downstream handlers read them without another DB round-trip.

Token refresh and logout endpoints

// internal/handlers/auth_handler.go

func (h *AuthHandler) Refresh(c *fiber.Ctx) error {
    refreshTokenVal := c.Cookies("refresh_token")
    if refreshTokenVal == "" {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing refresh token"})
    }
    userID, err := models.ValidateRefreshToken(h.queries, refreshTokenVal)
    if err != nil {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid refresh token"})
    }
    uid, _ := uuid.Parse(userID)
    user, err := h.queries.FindUserByID(context.Background(), uid)
    if err != nil {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "user not found"})
    }
    accessToken, _ := auth.GenerateAccessToken(h.cfg, user)
    return c.JSON(fiber.Map{"access_token": accessToken})
}

func (h *AuthHandler) Logout(c *fiber.Ctx) error {
    if val := c.Cookies("refresh_token"); val != "" {
        models.RevokeRefreshToken(h.queries, val)
    }
    c.Cookie(&fiber.Cookie{Name: "refresh_token", Value: "", MaxAge: -1, Path: "/"})
    return c.SendStatus(fiber.StatusNoContent)
}

func (h *AuthHandler) Me(c *fiber.Ctx) error {
    claims := c.Locals("claims").(*auth.Claims)
    // Build user from JWT claims — no DB round-trip
    user := dbpkg.User{
        Email:   claims.Email,
        Name:    sql.NullString{String: claims.Name, Valid: claims.Name != ""},
        Picture: sql.NullString{String: claims.Picture, Valid: claims.Picture != ""},
    }
    if uid, err := uuid.Parse(claims.UserID); err == nil {
        user.ID = uid
    }
    freshToken, _ := auth.GenerateAccessToken(h.cfg, user)
    return c.JSON(fiber.Map{
        "user":         fiber.Map{"id": claims.UserID, "email": claims.Email, "name": claims.Name, "picture": claims.Picture},
        "access_token": freshToken,
    })
}

/auth/me issues a fresh token on every call. This is intentional — it’s called on page load to restore session, so returning a fresh 15-min token avoids an immediate expiry edge case.

Route registration

// cmd/server/main.go (route setup)
authGroup := app.Group("/auth")
authGroup.Get("/google/login", oauthHandler.Login)
authGroup.Get("/google/callback", oauthHandler.Callback)
authGroup.Post("/refresh", authHandler.Refresh)
authGroup.Post("/logout", authHandler.Logout)
authGroup.Get("/me", auth.JWTAuth(cfg), authHandler.Me)

api := app.Group("/api", auth.JWTAuth(cfg))
api.Get("/products", productHandler.List)

Frontend: React + TypeScript

Auth context

The access token lives in React state (and a ref for interceptor access), never in localStorage:

// src/contexts/auth-context.tsx
export function AuthProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<AuthState>({
    user: null,
    accessToken: null,
    isLoading: true,
  })

  // Ref lets axios interceptors always read the latest token
  // without the interceptor needing to re-register on every state change
  const tokenRef = useRef<string | null>(null)

  const setTokens = useCallback((accessToken: string, user: User) => {
    tokenRef.current = accessToken
    setState({ user, accessToken, isLoading: false })
  }, [])

  const updateAccessToken = useCallback((token: string) => {
    tokenRef.current = token
    setState((prev) => ({ ...prev, accessToken: token }))
  }, [])

  const clearAuth = useCallback(() => {
    tokenRef.current = null
    setState({ user: null, accessToken: null, isLoading: false })
  }, [])

  const logout = useCallback(async () => {
    try {
      await apiClient.post('/auth/logout')
    } catch {
      // clear regardless of network errors
    } finally {
      queryClient.clear()
      clearAuth()
    }
  }, [clearAuth])

  // Register callbacks with axios — avoids circular import
  useEffect(() => {
    registerAuthCallbacks({ getAccessToken: () => tokenRef.current, updateAccessToken, clearAuth })
  }, [updateAccessToken, clearAuth])

  // Restore session on page load via HTTP-only refresh cookie
  useEffect(() => {
    apiClient
      .get<{ user: User; access_token: string }>('/auth/me')
      .then((res) => {
        tokenRef.current = res.data.access_token
        setState({ user: res.data.user, accessToken: res.data.access_token, isLoading: false })
      })
      .catch(() => setState((prev) => ({ ...prev, isLoading: false })))
  }, [])

  return (
    <AuthContext.Provider value={{ ...state, setTokens, updateAccessToken, logout }}>
      {children}
    </AuthContext.Provider>
  )
}

The tokenRef pattern solves a subtle problem: the axios interceptor is registered once, but needs to read the latest token. Without the ref, it would close over the initial null value.

Axios client with silent refresh

// src/lib/axios-client.ts
export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:3000',
  withCredentials: true,  // sends HTTP-only cookies cross-origin
})

let getAccessToken: () => string | null = () => null
let updateAccessToken: (token: string) => void = () => {}
let clearAuth: () => void = () => {}

export function registerAuthCallbacks(callbacks: {
  getAccessToken: () => string | null
  updateAccessToken: (token: string) => void
  clearAuth: () => void
}) {
  getAccessToken = callbacks.getAccessToken
  updateAccessToken = callbacks.updateAccessToken
  clearAuth = callbacks.clearAuth
}

// Queue for concurrent 401s while a refresh is in-flight
let isRefreshing = false
let failedQueue: Array<{ resolve: (token: string) => void; reject: (err: unknown) => void }> = []

function processQueue(error: unknown, token: string | null) {
  failedQueue.forEach(({ resolve, reject }) => (error ? reject(error) : resolve(token!)))
  failedQueue = []
}

// Attach token to every outbound request
apiClient.interceptors.request.use((config) => {
  const token = getAccessToken()
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

// On 401: refresh and retry once; queue concurrent failures
apiClient.interceptors.response.use(
  (res) => res,
  async (error) => {
    const original = error.config as typeof error.config & { _retry?: boolean }

    if (
      error.response?.status !== 401 ||
      original._retry ||
      original.url === '/auth/refresh'  // prevent infinite loop
    ) {
      return Promise.reject(error)
    }

    if (isRefreshing) {
      // Queue this request — it will retry once refresh completes
      return new Promise<string>((resolve, reject) => {
        failedQueue.push({ resolve, reject })
      }).then((token) => {
        original.headers.Authorization = `Bearer ${token}`
        return apiClient(original)
      })
    }

    original._retry = true
    isRefreshing = true

    try {
      const { data } = await apiClient.post<{ access_token: string }>('/auth/refresh')
      updateAccessToken(data.access_token)
      processQueue(null, data.access_token)
      original.headers.Authorization = `Bearer ${data.access_token}`
      return apiClient(original)
    } catch (err) {
      processQueue(err, null)
      clearAuth()
      return Promise.reject(err)
    } finally {
      isRefreshing = false
    }
  }
)

The request queue handles the race condition where multiple requests expire simultaneously. Without it, you’d fire N refresh requests and overwrite the token N times. With the queue, only one refresh fires; all other 401s wait and retry with the new token.

Login page

// src/routes/login.tsx
const GOOGLE_LOGIN_URL = `${import.meta.env.VITE_API_URL ?? 'http://localhost:3000'}/auth/google/login`

export default function LoginPage() {
  const { user, isLoading } = useAuth()

  if (isLoading) {
    return <div className="flex items-center justify-center min-h-screen">
      <p className="text-muted-foreground">Loading...</p>
    </div>
  }

  if (user) return <Navigate to="/products" replace />

  return (
    <div className="flex items-center justify-center min-h-screen bg-background px-4">
      <Card className="w-full max-w-sm">
        <CardHeader className="text-center">
          <CardTitle className="text-2xl">Welcome</CardTitle>
          <p className="text-muted-foreground text-sm mt-1">Sign in to access the store</p>
        </CardHeader>
        <CardContent>
          <Button className="w-full" onClick={() => { window.location.href = GOOGLE_LOGIN_URL }}>
            Sign in with Google
          </Button>
        </CardContent>
      </Card>
    </div>
  )
}

window.location.href triggers a full redirect — necessary for the OAuth flow since it crosses domains. isLoading: true on mount prevents a flash of the login form before session restoration completes.

OAuth callback handler

// src/routes/auth-callback.tsx
export default function AuthCallback() {
  const { setTokens } = useAuth()
  const navigate = useNavigate()
  const called = useRef(false)

  useEffect(() => {
    // React StrictMode mounts twice in dev — guard against double execution
    if (called.current) return
    called.current = true

    const params = new URLSearchParams(window.location.search)
    const accessToken = params.get('access_token')

    if (!accessToken) {
      navigate('/login', { replace: true })
      return
    }

    apiClient
      .get<{ user: User; access_token: string }>('/auth/me', {
        headers: { Authorization: `Bearer ${accessToken}` },
      })
      .then((res) => {
        setTokens(res.data.access_token, res.data.user)
        navigate('/products', { replace: true })
      })
      .catch(() => navigate('/login', { replace: true }))
  }, [setTokens, navigate])

  return (
    <div className="flex items-center justify-center min-h-screen">
      <p className="text-muted-foreground">Completing sign in...</p>
    </div>
  )
}

The callback immediately calls /auth/me with the access token from the URL to load user data and get a fresh token, then removes the token from the URL by navigating to /products. This minimizes how long the JWT is visible in the browser history.

Security considerations

What this implementation gets right:

  1. No tokens in localStorage — XSS cannot steal the refresh token (HTTP-only cookie) or the access token (memory only). Page refresh is handled via the cookie + /auth/me call.

  2. CSRF protection on OAuth flow — The state parameter is a random 32-byte value validated before the code exchange. LoadAndDelete means it can only be used once.

  3. Hashed refresh tokens — A stolen database dump reveals only SHA-256 hashes, not usable tokens. The plain token is only ever in the HTTP response and the browser cookie.

  4. Algorithm pinning — The ParseWithClaims callback checks t.Method.(*jwt.SigningMethodHMAC) explicitly. This blocks the alg: none downgrade attack.

  5. SameSite: Lax on the refresh cookie — Prevents CSRF attacks that would try to trigger a token refresh from a third-party site.

  6. CORS with explicit originwithCredentials: true on axios requires the backend to set Access-Control-Allow-Origin to the exact frontend origin, not *.

Known limitations to address in production:

  • State is stored in an in-memory sync.Map — won’t survive server restarts and doesn’t work across replicas. Use Redis with a TTL.
  • Refresh token rotation (issue a new refresh token on each use, invalidate the old one) is not implemented.
  • No rate limiting on /auth/refresh — should be added to prevent brute-force attempts.

Environment variables

# api/.env
DATABASE_URL=postgres://user:pass@localhost:5432/login_oauth?sslmode=disable
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
JWT_SECRET=a-long-random-string-at-least-32-chars
FRONTEND_URL=http://localhost:5173
ENV=development

# web/.env
VITE_API_URL=http://localhost:3000

Running locally

# Start PostgreSQL
docker compose up -d postgres

# Run migrations
cd api && make migrate-up

# Start Go API
go run ./cmd/server

# Start React dev server
cd web && pnpm dev

The complete working implementation is at github.com/phathdt/login-oauth.

go golang react typescript oauth2 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.