Fityra/Blog
ArticlesBuild in PublicDeep Divesfityra.app ↗
Fityra/Blog

Technical deep dives and lessons learned building the Fityra ecosystem.

RSSSitemap

Blog

  • All Articles
  • Build in Public
  • Technical Deep Dives
  • Product Updates

Ecosystem

  • fityra.app ↗
  • SaaSLift ↗
  • Extensions ↗

© 2026 Edson Mark. All rights reserved.

Chrome Extensions

OAuth in a Chrome Extension Is Not the Same as in a Web App

I thought connecting Google Calendar and Outlook from a Chrome extension would feel like normal web OAuth. The extension context changed the redirect, token exchange, storage, and error handling.

9 min read
chrome extensionsoauthgoogle calendaroutlookmicrosoft graphauthenticationmanifest v3
OAuth in a Chrome Extension Is Not the Same as in a Web App

I underestimated OAuth in a Chrome extension because I had done OAuth in web apps before.

That confidence lasted about one afternoon.

In a normal web app, the shape is familiar:

  1. Send the user to an authorization URL.
  2. The provider redirects back to your domain.
  3. Your server receives an authorization code.
  4. Your server exchanges the code for tokens.
  5. You store the session server-side or in a normal app database.

It is not effortless, but the mental model is clean. You own the domain. You control the callback route. Your backend is waiting there.

A Chrome extension changes that feeling.

When I started connecting calendar accounts for a browser extension, I kept reaching for normal web app instincts. I wanted a callback route. I wanted a clean server redirect. I wanted the extension to behave like a little web app with a weird UI.

It is not that.

A Chrome extension is its own runtime. It has a manifest, service worker, extension pages, restricted APIs, browser identity helpers, and a different trust boundary. OAuth still works, but the wiring is different enough that pretending it is a web app will make the implementation fragile.

This post is the version I wish I had read before building the first OAuth flow.

The first thing that felt wrong was the redirect

In a web app, the redirect URI is usually obvious:

https://example.com/api/oauth/callback

That URL belongs to you. The provider redirects there, your server handles it, and life makes sense.

In a Chrome extension, the user is not sitting inside your website. They may be clicking a button in an extension popup, an options page, or a content script UI. The extension does not naturally have a public HTTPS callback route of its own.

Chrome gives extensions a better tool: chrome.identity.launchWebAuthFlow().

The flow starts inside the extension, opens the provider authorization page, and lets Chrome observe the redirect. Chrome's identity API also gives you chrome.identity.getRedirectURL(), which produces a redirect URL for the extension, commonly under a chromiumapp.org origin.

The rough shape looks like this:

async function connectGoogleCalendar() {
  const redirectUri = chrome.identity.getRedirectURL('google')
 
  const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth')
  authUrl.searchParams.set('client_id', GOOGLE_CLIENT_ID)
  authUrl.searchParams.set('redirect_uri', redirectUri)
  authUrl.searchParams.set('response_type', 'code')
  authUrl.searchParams.set('scope', [
    'openid',
    'email',
    'https://www.googleapis.com/auth/calendar.readonly',
  ].join(' '))
  authUrl.searchParams.set('access_type', 'offline')
  authUrl.searchParams.set('prompt', 'consent')
 
  const responseUrl = await chrome.identity.launchWebAuthFlow({
    url: authUrl.toString(),
    interactive: true,
  })
 
  const code = new URL(responseUrl).searchParams.get('code')
  if (!code) throw new Error('OAuth completed without an authorization code')
 
  return exchangeCodeOnServer({ code, redirectUri })
}

That is the first important shift:

The extension starts the browser flow, but I still do not want the extension to handle every secret.

The extension should not become your backend

This was the mistake I almost made.

I had the authorization code in the extension. It felt tempting to exchange it directly from the extension by calling the token endpoint.

For some OAuth providers and public-client flows, you can build a version of this with PKCE and no client secret. But the broader product question is not only "can this request work?"

The question is:

Where do I want the security boundary to live?

For my calendar integration, I wanted a backend involved because it gave me a better place to:

  • Keep provider client secrets away from extension code when using confidential-client flows
  • Normalize Google and Microsoft token responses
  • Refresh tokens reliably
  • Revoke access when the user disconnects
  • Log token exchange failures without exposing sensitive values
  • Change provider implementation details without shipping a new extension

So the extension receives the authorization code, then sends it to my backend:

async function exchangeCodeOnServer(input: {
  code: string
  redirectUri: string
}) {
  const res = await fetch('https://api.example.com/oauth/google/exchange', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(input),
  })
 
  if (!res.ok) {
    throw new Error('Could not connect Google Calendar')
  }
 
  return res.json() as Promise<{
    accountId: string
    email: string
    expiresAt: string
  }>
}

The backend does the provider-specific exchange:

export async function exchangeGoogleCode(code: string, redirectUri: string) {
  const body = new URLSearchParams({
    code,
    client_id: process.env.GOOGLE_CLIENT_ID!,
    client_secret: process.env.GOOGLE_CLIENT_SECRET!,
    redirect_uri: redirectUri,
    grant_type: 'authorization_code',
  })
 
  const res = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body,
  })
 
  if (!res.ok) {
    throw new Error('Google token exchange failed')
  }
 
  return res.json()
}

That split made the extension simpler.

The extension owns the browser interaction. The backend owns the token contract.

That became my rule.

Google and Microsoft feel similar until they do not

At the flow level, Google Calendar and Outlook are similar.

Both ask the user to approve access. Both redirect with a code. Both have token endpoints. Both can return refresh tokens. Both require careful scope choices.

But the small details are different enough that I stopped trying to make one universal OAuth function too early.

For Google, I needed calendar scopes like:

https://www.googleapis.com/auth/calendar.readonly

For Microsoft, the authorization URL is different:

const authUrl = new URL(
  'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'
)
 
authUrl.searchParams.set('client_id', MICROSOFT_CLIENT_ID)
authUrl.searchParams.set('redirect_uri', redirectUri)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', [
  'offline_access',
  'User.Read',
  'Calendars.Read',
].join(' '))

The user-facing feature is the same:

Connect my calendar.

The provider implementation is not the same.

I eventually made a small provider adapter instead of a giant OAuth helper:

interface OAuthProvider {
  id: 'google' | 'microsoft'
  buildAuthUrl(input: { redirectUri: string; state: string }): string
  exchangeCode(input: { code: string; redirectUri: string }): Promise<TokenSet>
  refresh(input: { refreshToken: string }): Promise<TokenSet>
}

This kept the shared logic shared, but left provider behavior in provider files.

That sounds obvious now. It was less obvious when I was tired and trying to make the first connection button work.

State matters more than I wanted it to

OAuth has a state parameter for a reason.

In a web app, it is common to store state in a server-side session or signed cookie. In an extension, you need to be more deliberate.

Before launching the flow, I generate a state value and store a pending connection record:

async function startOAuth(provider: 'google' | 'microsoft') {
  const state = crypto.randomUUID()
  const redirectUri = chrome.identity.getRedirectURL(provider)
 
  await chrome.storage.session.set({
    [`oauth:${state}`]: {
      provider,
      redirectUri,
      createdAt: Date.now(),
    },
  })
 
  const url = buildProviderAuthUrl({ provider, redirectUri, state })
 
  const responseUrl = await chrome.identity.launchWebAuthFlow({
    url,
    interactive: true,
  })
 
  return finishOAuth(responseUrl)
}

Then I verify it when the redirect comes back:

async function finishOAuth(responseUrl: string) {
  const url = new URL(responseUrl)
  const code = url.searchParams.get('code')
  const state = url.searchParams.get('state')
 
  if (!code || !state) {
    throw new Error('OAuth response was missing required values')
  }
 
  const key = `oauth:${state}`
  const stored = await chrome.storage.session.get(key)
  const pending = stored[key]
 
  if (!pending) {
    throw new Error('OAuth state was not recognized')
  }
 
  await chrome.storage.session.remove(key)
 
  return exchangeCodeOnServer({
    provider: pending.provider,
    code,
    redirectUri: pending.redirectUri,
  })
}

This is not exciting code, but it is the kind of code that makes authentication feel less haunted.

Without state, the flow is too trusting. With state, I know the redirect belongs to an auth flow the extension actually started.

The popup is a bad place to depend on

Chrome extension popups are temporary.

The user clicks the extension icon. A small popup appears. If the popup loses focus, it can close. If the OAuth flow opens another window or tab, the popup may not be alive when the user returns.

That surprised me the first time.

In a web app, you can assume the page that started the OAuth flow will probably still exist. In an extension popup, that assumption is weak.

So I moved important OAuth control into the service worker or extension pages, and treated the popup as a trigger and status display.

The popup can say:

  • Connect Google Calendar
  • Connecting...
  • Connected as user@example.com
  • Connection failed, try again

But it should not be the only place that remembers the pending flow.

That is why chrome.storage.session and backend state mattered. The UI could disappear and come back without losing the entire flow.

This changed how I designed the product too. I stopped thinking of the popup as a mini web app. It is more like a small remote control for extension state.

Token storage is not just a technical choice

Where should tokens live?

There is no single answer that fits every extension.

For my use case, I did not want long-lived provider refresh tokens casually sitting in extension storage if I could avoid it. Chrome extension storage is useful, but it is still on the client. If the product has a backend, the backend is usually a better place for sensitive long-lived tokens.

The extension can store an account reference:

await chrome.storage.local.set({
  calendarAccount: {
    provider: 'google',
    accountId: result.accountId,
    email: result.email,
    connectedAt: Date.now(),
  },
})

Then the backend stores and refreshes provider tokens.

When the extension needs upcoming meetings, it calls my API:

const res = await fetch('https://api.example.com/calendar/upcoming', {
  headers: {
    authorization: `Bearer ${extensionSessionToken}`,
  },
})

This creates another question: how does the extension authenticate to my backend?

That is a separate product auth problem. It might be a license key, a user account, a signed extension session, or some other app-specific token. The important thing is not to confuse provider OAuth tokens with your own product session.

Google or Microsoft tells me the user authorized calendar access.

My backend session tells me this extension instance is allowed to ask my API for data.

Those are related, but not identical.

Refresh tokens are where the real product starts

Getting the first access token feels like the finish line.

It is not.

The product only becomes useful when it still works tomorrow.

Access tokens expire. Refresh tokens may rotate. Users revoke access. Providers return errors. Consent screens change. Scopes may need review. A user may connect one account, then try another.

I built the first happy path too quickly. It connected, fetched events, and made me feel like the integration was done.

Then I hit the real cases:

  • The access token expired.
  • The refresh token was missing because the prompt behavior was wrong.
  • The provider returned invalid_grant.
  • The user disconnected the app from their account settings.
  • The backend refreshed successfully but the extension still showed stale account state.

That is when I added a clearer connection status model:

type CalendarConnectionStatus =
  | { state: 'disconnected' }
  | { state: 'connecting' }
  | { state: 'connected'; email: string; provider: 'google' | 'microsoft' }
  | { state: 'needs_reconnect'; reason: string }

This is the kind of model that makes the UI honest.

If the token is gone, do not pretend the calendar is connected. If the provider needs fresh consent, say reconnect. If the backend is temporarily down, say sync failed.

OAuth errors are not just backend errors. They become user trust moments.

Scopes should be boring

The temptation with OAuth scopes is to ask for a little more "just in case."

That is a bad instinct.

For a meeting reminder, I do not need permission to edit someone's calendar. I need to read upcoming events. That difference matters on the consent screen.

For Google Calendar, read-only access is easier to explain than full calendar access.

For Microsoft Graph, Calendars.Read is easier to justify than broader mailbox or profile scopes.

I learned to write the permission explanation before finalizing the scope list.

If I cannot explain a scope in one sentence, I probably should not request it yet.

The product copy might say:

Used to read upcoming calendar events so the extension can show meeting reminders. The extension does not create, edit, or delete calendar events.

That sentence becomes a forcing function. If the implementation asks for more than the sentence admits, something is wrong.

Local development is awkward unless you plan for it

OAuth setup always has an environment problem.

In a web app, local development usually means adding localhost callback URLs.

In a Chrome extension, the redirect URL can depend on the extension ID. Development and production IDs can differ. If you load an unpacked extension locally, it may have one ID. The published extension has another.

That means provider configuration needs to account for the real redirect URL from:

chrome.identity.getRedirectURL()

I now log the redirect URL in development and copy that exact value into the provider console.

Not "something like it."

The exact value.

OAuth providers are picky because they should be. A redirect URI mismatch is annoying, but it is also one of the guardrails that keeps tokens from being sent somewhere unexpected.

The useful architecture

The architecture I like now is simple:

  1. The extension starts OAuth with chrome.identity.launchWebAuthFlow().
  2. The extension uses chrome.identity.getRedirectURL() as the redirect URI.
  3. The extension generates and verifies state.
  4. The extension sends the authorization code to my backend.
  5. The backend exchanges the code and stores provider tokens.
  6. The extension stores only connection metadata and my app session.
  7. The backend refreshes tokens and exposes a small product API.
  8. The extension shows honest reconnect states when provider auth breaks.

This is not the only valid architecture.

But it is the one that made the most sense for a real product instead of a demo.

What I would tell myself before starting

I would say this:

Do not copy your web app OAuth flow and hope the extension behaves.

Start from the extension runtime. Understand where the popup can die. Understand what the service worker owns. Understand the redirect URL Chrome gives you. Understand which tokens should never live in client code. Understand what each provider scope means to a normal user reading a consent screen.

Most OAuth tutorials show the happy path. The product work is in everything after the happy path:

  • Reconnect flows
  • Revocation
  • Expired tokens
  • Provider-specific edge cases
  • Environment setup
  • Clear permission copy
  • Safe token storage
  • UI state that tells the truth

That is where the feature becomes trustworthy.

The lesson that stuck

OAuth in a Chrome extension is not harder because the code is impossible.

It is harder because the boundaries are different.

A web app has a server, a domain, and a page lifecycle you mostly control.

A Chrome extension has a popup that can disappear, a service worker that wakes and sleeps, extension pages, browser APIs, provider redirects, and a user who is already cautious about extensions asking for account access.

Once I accepted that, the implementation became less frustrating.

I stopped trying to make the extension behave like a web app.

I let it behave like an extension.

That was the turn.

References

  • Chrome identity API
  • Google OAuth 2.0 documentation
  • Microsoft identity platform OAuth 2.0 authorization code flow

View Chrome Extensions

Productivity-focused Chrome extensions built for developers and power users.

Browse extensions →
Share
PreviousBuilding an App-Store UI for My Chrome Extensions with Next.jsNextI Had 22 Chrome Extensions Installed. I Had No Idea What Half of Them Could Do.

Related articles

Injecting a UI Overlay Into Every Webpage Without Breaking Any of Them
Chrome Extensionschrome extensionscontent scripts

Injecting a UI Overlay Into Every Webpage Without Breaking Any of Them

The first version of my overlay worked beautifully on a blank test page. That was the trap. I had a simple reminder panel for a Chrome extension. The extension needed to show a full page meeting reminder on top of whatever tab I was already using. Not a quiet notification in the corner. Not a calendar popup that...

9 min readRead more
Chrome's history page is a junk drawer. I gave mine a memory instead.
Chrome Extensionsbrowser memorychrome history

Chrome's history page is a junk drawer. I gave mine a memory instead.

Chrome's history page is a junk drawer. I gave mine a memory instead. I have lost the same useful page more times than I want to admit. Not because I forgot how to search. Not because the page disappeared. Usually it was still there, buried somewhere inside , sitting between a login redirect, a docs page I opened by...

9 min readRead more
How I Check Whether a Chrome Extension Is Asking for Too Much Access
Chrome Extensionschrome extensionsextension permissions

How I Check Whether a Chrome Extension Is Asking for Too Much Access

How I Check Whether a Chrome Extension Is Asking for Too Much Access I used to install Chrome extensions the way most people install them: I saw a useful feature, checked a few reviews, clicked install, and moved on. That sounds normal because it is normal. Extensions are supposed to feel lightweight. A screenshot...

10 min readRead more

On this page

  • The first thing that felt wrong was the redirect
  • The extension should not become your backend
  • Google and Microsoft feel similar until they do not
  • State matters more than I wanted it to
  • The popup is a bad place to depend on
  • Token storage is not just a technical choice
  • Refresh tokens are where the real product starts
  • Scopes should be boring
  • Local development is awkward unless you plan for it
  • The useful architecture
  • What I would tell myself before starting
  • The lesson that stuck
  • References