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.

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:
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.
In a web app, the redirect URI is usually obvious:
https://example.com/api/oauth/callbackThat 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.
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:
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.
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.readonlyFor 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.
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.
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:
user@example.comBut 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.
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.
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:
invalid_grant.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.
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.
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 architecture I like now is simple:
chrome.identity.launchWebAuthFlow().chrome.identity.getRedirectURL() as the redirect URI.state.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.
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:
That is where the feature becomes trustworthy.
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.
Productivity-focused Chrome extensions built for developers and power users.

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...

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...

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...