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:
-
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/mecall. -
CSRF protection on OAuth flow — The
stateparameter is a random 32-byte value validated before the code exchange.LoadAndDeletemeans it can only be used once. -
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.
-
Algorithm pinning — The
ParseWithClaimscallback checkst.Method.(*jwt.SigningMethodHMAC)explicitly. This blocks thealg: nonedowngrade attack. -
SameSite: Laxon the refresh cookie — Prevents CSRF attacks that would try to trigger a token refresh from a third-party site. -
CORS with explicit origin —
withCredentials: trueon axios requires the backend to setAccess-Control-Allow-Originto 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.