· Webentwicklung · 6 minuten Lesezeit
PKCE OAuth in einer Chrome MV3 Extension mit chrome.identity.launchWebAuthFlow
chrome.identity.launchWebAuthFlow ist der richtige Einstiegspunkt für PKCE OAuth in Chrome Extensions. Wie Token-Speicherung in chrome.storage.sync, automatische Erneuerung und die Extension-spezifische Redirect-URI zusammenspielen.

Inhalt
- Warum eine Chrome Extension eigene Auth-Logik braucht
- Die PKCE-Implementierung
- Die Redirect-URI
- Der Login-Flow
- Interaktiver PKCE-Flow und Container-Topologie
- Token-Speicherung in chrome.storage.sync
- Automatische Token-Erneuerung
- Bearer Token in API-Calls
- Auth-State im Popup
- VITE_AUTH_ENABLED Flag
- Alle Artikel der Serie
Warum eine Chrome Extension eigene Auth-Logik braucht
Eine normale Web-App nutzt Redirects: Der Browser verlässt die Seite, geht zur Login-UI, kommt mit einem Code zurück, die App tauscht den Code gegen ein Token.
In einer Chrome Extension gibt es keinen “Verlassen und Zurückkehren”-Mechanismus. Der Background Service Worker hat kein DOM. Das Content Script hat keinen kontrollierten Lebenszyklus, der einen Auth-Flow sauber abwarten kann. Das Popup schließt sich bei jeder Interaktion. Die Drei-Kontext-Architektur einer Chrome Extension MV3 macht OAuth zu einem eigenen Problem.
Die Lösung ist chrome.identity.launchWebAuthFlow. Die Chrome-API öffnet einen kontrollierten Browser-Tab für den OAuth-Flow, fängt den Redirect ab und gibt die Redirect-URI mit allen Query-Parametern zurück, ohne dass die Extension selbst eine öffentliche URL bereitstellen muss.
Die PKCE-Implementierung
PKCE (Proof Key for Code Exchange) ist der richtige Flow für native Apps und Extensions, weil kein Client Secret gespeichert werden muss. Der Flow läuft so:
- Extension generiert einen
code_verifier(zufällige Zeichenkette) - Extension berechnet den
code_challenge(SHA-256-Hash des Verifiers, base64url-kodiert) - Extension öffnet die Authorization URL mit
code_challengeundcode_challenge_method=S256 - Nutzer loggt sich ein, Zitadel redirectet zur
redirect_urimit einemcode - Extension tauscht
code+code_verifiergegen Access Token und Refresh Token
// code_verifier: cryptographically random string
const generateCodeVerifier = (): string => {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
};
// code_challenge: SHA-256 of the verifier
const generateCodeChallenge = async (verifier: string): Promise<string> => {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
};Die Redirect-URI
Zitadel muss wissen, wohin es nach dem Login redirecten soll. Für Chrome Extensions ist das keine http://localhost-URL, sondern eine Extension-URL:
https://{extension-id}.chromiumapp.org/callbackchrome.identity.getRedirectURL() gibt diese URL zurück. Sie ist stabil für eine Extension-ID, ändert sich aber mit jeder neuen Extension (jede unpublished extension hat eine andere zufällige ID).
const REDIRECT_URI = chrome.identity.getRedirectURL('callback');
// Example: "https://abcdefghijklmnop.chromiumapp.org/callback"Diese URL muss in der Zitadel-Konsole als erlaubte Redirect-URI für die Native/PKCE-App eingetragen werden. Ohne das lehnt Zitadel den Authorization Request ab.
Der Login-Flow
export const signIn = async (): Promise<void> => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const authUrl = new URL(`${ZITADEL_DOMAIN}/oauth/v2/authorize`);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('scope', 'openid profile email offline_access');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
const responseUrl = await chrome.identity.launchWebAuthFlow({
url: authUrl.toString(),
interactive: true,
});
if (!responseUrl) throw new Error('Auth flow cancelled');
const url = new URL(responseUrl);
const code = url.searchParams.get('code');
if (!code) throw new Error('No authorization code in response');
const tokens = await exchangeCodeForTokens(code, codeVerifier);
await storeTokens(tokens);
};launchWebAuthFlow mit interactive: true öffnet ein Popup-Fenster für den Login. Das Fenster schließt sich automatisch, sobald Zitadel zur Redirect-URI weiterleitet. Der Rückgabewert ist die vollständige Redirect-URL mit dem Code.
Interaktiver PKCE-Flow und Container-Topologie
Die folgende Sandbox zeigt den Flow Schritt für Schritt im Browser und blendet die kompakten Container-Details erst auf Desktop ein.
Token-Speicherung in chrome.storage.sync
Tokens werden in chrome.storage.sync gespeichert, nicht in localStorage. Der Unterschied: chrome.storage.sync synchronisiert den Stand über alle Chrome-Instanzen desselben Google-Accounts. Nutzer sind auf allen Geräten gleichzeitig eingeloggt.
const storeTokens = async (tokens: TokenResponse): Promise<void> => {
await chrome.storage.sync.set({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
idToken: tokens.id_token,
});
};Die expiresAt-Zeit ist wichtig für die automatische Token-Erneuerung.
Automatische Token-Erneuerung
Access Tokens sind kurzlebig (typisch 1 Stunde). Refresh Tokens sind langlebig. Vor jedem API-Call prüft die Extension, ob das Access Token noch gültig ist:
export const getValidAccessToken = async (): Promise<string | null> => {
const { accessToken, refreshToken, expiresAt } = await chrome.storage.sync.get([
'accessToken',
'refreshToken',
'expiresAt',
]);
if (!accessToken || !refreshToken) return null;
// Token still valid (60s buffer for clock drift)
if (expiresAt && Date.now() < expiresAt - 60_000) {
return accessToken;
}
// Token expired: refresh with the refresh token
try {
const tokens = await refreshAccessToken(refreshToken);
await storeTokens(tokens);
return tokens.access_token;
} catch {
// Refresh token invalid: user must sign in again
await clearTokens();
return null;
}
};Die 60-Sekunden-Puffer verhindert, dass ein Token in dem Moment abläuft, in dem der API-Call abgesetzt wird.
Bearer Token in API-Calls
Alle API-Calls aus dem Background Service Worker hängen das Token automatisch an:
const token = await getValidAccessToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}/ingest`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});Wenn getValidAccessToken() null zurückgibt, wird der Request ohne Token abgesetzt. Das Backend lehnt ihn mit 401 ab. Die Extension zeigt dann den Login-Status im Popup.
Auth-State im Popup
Das Popup zeigt den aktuellen Auth-Status und einen Login/Logout-Button. Da das Popup keinen persistenten State hat, liest es den Status direkt aus chrome.storage.sync:
const loadAuthState = async () => {
const { accessToken, expiresAt } = await chrome.storage.sync.get(['accessToken', 'expiresAt']);
const isAuthenticated = !!accessToken && expiresAt > Date.now();
if (isAuthenticated) {
statusEl.textContent = 'Angemeldet';
loginBtn.textContent = 'Abmelden';
} else {
statusEl.textContent = 'Nicht angemeldet';
loginBtn.textContent = 'Anmelden';
}
};Der Login-Button delegiert den eigentlichen OAuth-Flow an den Background Service Worker, weil chrome.identity.launchWebAuthFlow eine Nutzer-Geste erfordert und im Popup-Kontext mit Zuverlässigkeitsproblemen kämpft:
loginBtn.addEventListener('click', async () => {
await chrome.runtime.sendMessage({ action: 'sign-in' });
await loadAuthState();
});Der Background Service Worker hält den Auth-State-kontext stabiler als das Popup.
VITE_AUTH_ENABLED Flag
Für lokale Entwicklung ohne laufenden Zitadel-Container gibt es einen Feature-Flag:
const AUTH_ENABLED = import.meta.env.VITE_AUTH_ENABLED === 'true';Wenn false, werden keine Token geprüft, kein Login gezeigt, alle API-Calls gehen ohne Bearer-Token raus. Das Backend injiziert dann eine synthetische Identität (dev-user-local).
Das ermöglicht lokale Entwicklung an Features, die mit Auth nichts zu tun haben, ohne den vollen Zitadel-Stack starten zu müssen.
Abbildung: Das Popup zeigt zwei Zustaende: links “Nicht angemeldet” mit Anmelden-Button, rechts “Angemeldet” mit Benutzername und Abmelden-Button. Der Status wird direkt aus chrome.storage.sync gelesen.
Alle Artikel der Serie
- Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
- RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
- AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
- Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
- Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
- Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
- Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
- tsconfig und Vite:
Node16vs.bundler, warum Vite eigene Regeln hat: Artikel lesen - Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
- Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
- Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
- Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
- PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
- Instagram Karussell vollständig erfassen mit MutationObserver: Artikel lesen
- Notiz und Tags beim Screenshot-Speichern: Artikel lesen
- Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
- Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
- Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
- PKCE OAuth in einer Chrome MV3 Extension (dieser Artikel)
- React Frontend mit react-oidc-context und Zitadel: Artikel lesen
- Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
Du baust OAuth-Authentifizierung für eine Chrome Extension? Lass uns das gemeinsam einschätzen.



