How to implement Google OAuth in browser extension without “Tabs” permission

Published: (December 7, 2025 at 07:15 AM EST)
4 min read
Source: Dev.to

Source: Dev.to

The Architecture: How It Works

We aren’t doing authentication inside the extension (because the popup page can close in the middle of the flow).
Instead, we handle it on our website and pass the session token to the extension.

Flow

  1. User installs the extension and is redirected to your web app’s signup page.
  2. User authenticates using Google OAuth or email/password on the web app.
  3. After successful authentication, tokens are exposed in hidden DOM elements.
  4. A content script running on your web app extracts these tokens.
  5. Tokens are sent to the extension’s background script.
  6. Background script creates a session and stores it locally.
  7. Both the extension and web app are now in sync.

Setting up the Web App

First, create an authentication context in your Next.js (or React + Vite) application to handle the user’s state. Below we use Supabase’s onAuthStateChange listener to detect when a user logs in.

Step 1: The Auth Context

Create contexts/AuthContext.tsx. This provider manages the user session and makes it available throughout your app.

// contexts/AuthContext.tsx
'use client';

import React, { createContext, useContext, useEffect, useState } from 'react';
import supabase from '@/lib/supabase/supabaseClient';

type AuthContextType = {
  user: SupabaseUser | undefined;
  session: SupabaseSession | undefined;
  loading: boolean;
  logout: () => Promise;
};

type AuthProviderProp = {
  children: React.ReactNode;
};

const AuthContext = createContext(undefined);

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

export function AuthProvider({ children }: AuthProviderProp) {
  const [user, setUser] = useState(undefined);
  const [session, setSession] = useState(undefined);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    supabase.auth.onAuthStateChange((eventName, session) => {
      console.log('Auth state changed:', eventName, session);

      if (eventName === 'INITIAL_SESSION' && session?.user) {
        setUser(session.user);
        setSession(session);
      } else if (eventName === 'SIGNED_IN' && session?.user) {
        setUser(session.user);
        setSession(session);
      } else if (eventName === 'SIGNED_OUT') {
        setUser(undefined);
        setSession(undefined);
      }
    });
  }, []);

  const value = {
    user,
    session,
    loading,
    logout: () => supabase.auth.signOut(),
  };

  return {children};
}

The onAuthStateChange listener fires whenever the authentication state changes, allowing us to update the UI and expose tokens when the user successfully logs in.

Step 2: Wrap Your App

Wrap your application (e.g., app/layout.tsx) with AuthProvider so the entire app has access to the user state.

// app/layout.tsx
import { AuthProvider } from '@/contexts/AuthContext';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    
      
        {children}
      
    
  );
}

Step 3: Create the Authentication Page

Build the page where users can sign up or log in.

// app/auth/page.tsx
'use client';

import { useState } from 'react';
import supabase from '@/lib/supabase/supabaseClient';

export default function AuthPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);

  async function handleRegister() {
    setLoading(true);
    const { data, error } = await supabase.auth.signUp({
      email: email.trim(),
      password,
      options: {
        emailRedirectTo: process.env.NEXT_PUBLIC_AUTH_SUCCESS_URL,
      },
    });
    setLoading(false);

    if (error) {
      console.error('Registration error:', error);
      // Handle error (show toast, etc.)
    }
  }

  async function handleGoogleLogin() {
    await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo: process.env.NEXT_PUBLIC_AUTH_SUCCESS_URL,
        queryParams: {
          access_type: 'offline',
          prompt: 'consent',
        },
      },
    });
  }

  return (
    
      
## Sign Up for Your Extension

       setEmail(e.target.value)}
        placeholder="Email"
      />

       setPassword(e.target.value)}
        placeholder="Password"
      />

      
        Sign Up with Email
      

      Continue with Google
    
  );
}

Step 4: Expose Tokens via Hidden DOM Elements

Add hidden elements (e.g., in the footer) that contain the authentication tokens. The extension’s content script will read these elements.

// components/Footer.tsx
'use client';

import { useAuth } from '@/contexts/AuthContext';

const Footer = () => {
  const { session, user } = useAuth();

  return (
    <>
      {/* Hidden elements for the extension's content script */}
      {session && (
        
          {user?.email}
          {session?.access_token}
          {session?.refresh_token}
        
      )}

      {/* Your regular footer content */}
      {/* Footer content */}
    
  );
};

export default Footer;

The sr-only class (screen‑reader only) keeps the elements invisible while still present in the DOM for the extension to access.

Building the Chrome Extension

Step 5: Create a Content Script to Extract Tokens

The content script runs on your web app’s domain, watches for the hidden token elements, and forwards the tokens to the background script. Below is an example using the WXT framework (you can adapt it to a plain content script).

// authContentScript.ts
export default defineContentScript({
  matches: ['https://your-web-app.com/*'], // Replace with your domain
  allFrames: true,
  runAt: 'document_idle',
  main() {
    console.log('Auth content script initialized');

    function observeAuthTokens() {
      const observer = new MutationObserver(() => {
        const accessTokenEl = document.querySelector('.accessTokenDiv');
        const refreshTokenEl = document.querySelector('.refreshTokenDiv');
        const emailEl = document.querySelector('.userEmailDiv');

        if (accessTokenEl && refreshTokenEl && emailEl) {
          const tokens = {
            accessToken: accessTokenEl.textContent,
            refreshToken: refreshTokenEl.textContent,
            email: emailEl.textContent,
          };
          // Send tokens to background script
          chrome.runtime.sendMessage({ type: 'SET_TOKENS', payload: tokens });
        }
      });

      observer.observe(document.body, { childList: true, subtree: true });
    }

    observeAuthTokens();
  },
});

The script watches for changes in the DOM, extracts the token values when they appear, and sends them to the extension’s background script via chrome.runtime.sendMessage. From there you can store the session using the storage permission.

Back to Blog

Related posts

Read more »

Custom auth in Payload

!Cover image for Custom auth in Payloadhttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads...