A practical guide to implementing OAuth 2.0 with PKCE using SvelteKit and FastAPI, supporting both web and mobile (Android/iOS via Capacitor).
OAuth 2.0 is an authorization framework that allows third-party applications to access user resources without exposing credentials. Instead of sharing passwords, users authorize apps to act on their behalf.
Example: When you click "Login with Google", you're redirected to Google's login page. After authenticating, Google asks if you want to grant the app access to your profile. If you agree, Google sends back a token the app can use.
The service that authenticates users and issues tokens (e.g., Google, Authentik).
The API that holds protected resources (e.g., Google's user profile API).
Your application requesting access to user resources.
| Token | Purpose | Lifetime |
|---|---|---|
| Access Token | Authorizes API requests | Short (minutes to hours) |
| Refresh Token | Obtains new access tokens | Long (days to months) |
| ID Token | Contains user identity info (OpenID Connect) | Short |
Permissions requested by the client. Examples:
openid - Required for OpenID Connectprofile - User's name, pictureemail - User's email addressPKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. It's required for public clients and recommended for all OAuth flows.
┌──────────┐ ┌─────────────────┐
│ Browser │ │ Auth Server │
│ (Client) │ │ (Google/etc) │
└────┬─────┘ └────────┬────────┘
│ │
│ 1. Generate code_verifier (random string) │
│ 2. Create code_challenge = SHA256(verifier) │
│ │
│ 3. Redirect to /authorize ─────────────────>│
│ ?response_type=code │
│ &client_id=xxx │
│ &redirect_uri=http://localhost/callback │
│ &scope=openid profile email │
│ &state=random123 │
│ &code_challenge=abc123 │
│ &code_challenge_method=S256 │
│ │
│ User logs in & consents
│ │
│ 4. Redirect to callback <───────────────────│
│ ?code=AUTH_CODE&state=random123 │
│ │
│ 5. POST /token ────────────────────────────>│
│ grant_type=authorization_code │
│ &code=AUTH_CODE │
│ &redirect_uri=http://localhost/callback │
│ &client_id=xxx │
│ &code_verifier=original_verifier ←──────── Proves we initiated the request
│ │
│ 6. Receive tokens <─────────────────────────│
│ { access_token, refresh_token, id_token }│
│ │
An attacker who intercepts the authorization code cannot exchange it without the original verifier.
For security, ALL OAuth token exchanges go through the backend - even for public clients like Authentik. This keeps OAuth URLs and any secrets server-side only.
Frontend Backend OAuth Provider
│ │ │
│ 1. Redirect to provider ─────────────────────────────> │
│ │ │
│ 2. Callback with code <─────────────────────────────── │
│ │ │
│ 3. POST /api/login/{provider}/token │
│ {code, code_verifier} │ │
│ ─────────────────────> │ │
│ │ 4. Exchange code + secret │
│ │ ─────────────────────────> │
│ │ │
│ │ 5. Tokens │
│ │ <───────────────────────── │
│ 6. Tokens + userinfo │ │
│ <───────────────────── │ │
frontend/src/lib/auth/pkce.ts)// Generate random string for code_verifier
export function generateRandomString(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
return Array.from(crypto.getRandomValues(new Uint8Array(length)))
.map((x) => chars[x % chars.length])
.join('');
}
// Create code_challenge from verifier using SHA-256
export async function generateCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
frontend/src/lib/auth/auth.service.ts)export async function startLogin(providerName: AuthProvider) {
const provider = providers[providerName];
const codeVerifier = generateRandomString(128);
const codeChallenge = await generateCodeChallenge(codeVerifier);
const nonce = generateRandomString(32);
if (isWeb()) {
// Web: store PKCE in sessionStorage, state is just the nonce
sessionStorage.setItem(`pkce_${providerName}`, codeVerifier);
sessionStorage.setItem(`state_${providerName}`, nonce);
}
// Mobile: encode verifier in state so the trampoline page can use it
const state = isWeb() ? nonce : `${nonce}.${codeVerifier}`;
const params = new URLSearchParams({
response_type: 'code',
client_id: provider.clientId,
redirect_uri: provider.redirectUri,
scope: provider.scope,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `${provider.authorizeUrl}?${params}`;
}
export async function handleOAuthCallback(
providerName: AuthProvider,
code: string,
state: string
): Promise<LoginInfo> {
const savedState = sessionStorage.getItem(`state_${providerName}`);
const verifier = sessionStorage.getItem(`pkce_${providerName}`);
if (!verifier || state !== savedState) {
throw new Error('Invalid OAuth state');
}
// Exchange code via backend, get tokens + userinfo
// Clean up sessionStorage after
}
Frontend only needs public info (no secrets, no token URLs):
// Authentik
export const authentikProvider: OAuthProviderConfig = {
name: 'authentik',
authorizeUrl: `${AUTHENTIK_URL}/application/o/authorize/`,
clientId: 'your-client-id',
redirectUri: 'http://localhost:3000/callback/authentik',
scope: 'openid profile email'
};
// Google
export const googleProvider: OAuthProviderConfig = {
name: 'google',
authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
clientId: 'your-client-id',
redirectUri: 'http://localhost:3000/callback/google',
scope: 'openid profile email'
};
@router.post("/{provider}/token")
async def token_exchange(request: TokenRequest, provider: Literal["google", "authentik"]):
if provider == "google":
return await _exchange_google(request)
else:
return await _exchange_authentik(request)
async def _exchange_google(request: TokenRequest):
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"grant_type": "authorization_code",
"code": request.code,
"redirect_uri": request.redirect_uri,
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET, # Secret stays on server
"code_verifier": request.code_verifier,
},
)
# ... fetch userinfo and return
This template supports Android/iOS via Capacitor. The key challenge is that OAuth providers don't allow custom URL schemes (e.g., authtemplate://) as redirect URIs for web client types. The solution is the trampoline pattern.
Platform detection uses build-time VITE_PLATFORM env var with runtime Capacitor checks:
// frontend/src/lib/platform/detect.ts
const isMobile = import.meta.env.VITE_PLATFORM === 'mobile';
export function isWeb(): boolean {
return !isMobile;
}
export async function getPlatform(): Promise<Platform> {
if (!isMobile) return 'web';
const { Capacitor } = await import('@capacitor/core');
const platform = Capacitor.getPlatform();
if (platform === 'android') return 'android';
if (platform === 'ios') return 'ios';
return 'web';
}
Build distinction (vite.config.ts):
VITE_PLATFORM=mobile): Capacitor plugins are bundledSince Google doesn't allow custom URL schemes as redirect URIs:
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Mobile App │ │ Trampoline │ │ OAuth Provider │
│ (Capacitor) │ │ (Prod Server)│ │ (Google/etc) │
└──────┬──────┘ └──────┬───────┘ └────────┬────────┘
│ │ │
│ 1. Open Chrome browser │
│ with authorize URL ───────────────────>│
│ redirect_uri = prodUrl/callback/android│
│ state = nonce.verifier (PKCE in state) │
│ │
│ │ 2. Redirect with code│
│ │<──────────────────────│
│ │ │
│ │ 3. Extract verifier │
│ │ from state │
│ │ 4. Exchange code │
│ │ for tokens ───────>│
│ │ │
│ │ 5. Receive tokens <───│
│ │ │
│ 6. Deep link: │ │
│ authtemplate:// │ │
│ auth?data=JSON │ │
│<──────────────────│ │
│ │ │
│ 7. Parse LoginInfo│ │
│ Store in auth │ │
│ Navigate to / │ │
Why encode PKCE verifier in state?
Android may kill the app while the user is in Chrome. PKCE verifier stored in sessionStorage would be lost. Encoding it in the OAuth state parameter ensures the trampoline page has everything needed. The backend's client_secret maintains security.
The app listens for deep links in +layout.svelte:
onMount(() => {
if (!isWeb()) {
import('@capacitor/app').then(({ App }) => {
// Warm start: app in background
App.addListener('appUrlOpen', ({ url }) => {
processDeepLink(url);
});
// Cold start: app killed, relaunched via deep link
App.getLaunchUrl().then((result) => {
if (result?.url) processDeepLink(result.url);
});
});
}
});
Located at frontend/src/routes/callback/android/{provider}/+page.svelte:
nonce.verifier format)/api/login/meLoginInfo JSON and redirects to app via deep linkauth-template/
├── backend/
│ ├── src/
│ │ ├── main.py # FastAPI app, CORS
│ │ └── router/auth.py # Token exchange for all providers
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── lib/
│ │ │ ├── auth/
│ │ │ │ ├── auth.service.ts # Login, callback, deep link handler
│ │ │ │ ├── pkce.ts # PKCE utilities
│ │ │ │ └── providers/ # Provider configs (public info only)
│ │ │ ├── platform/
│ │ │ │ ├── detect.ts # isWeb(), getPlatform()
│ │ │ │ ├── types.ts # Platform type
│ │ │ │ └── index.ts # Re-exports
│ │ │ ├── stores/auth.store.ts # Auth state management
│ │ │ └── types/auth.ts # TypeScript types
│ │ └── routes/
│ │ ├── +layout.svelte # Deep link listener (mobile)
│ │ ├── login/+page.svelte # Login page
│ │ └── callback/
│ │ ├── google/ # Web callback
│ │ ├── authentik/ # Web callback
│ │ └── android/ # Mobile trampoline callbacks
│ │ ├── google/
│ │ └── authentik/
│ ├── capacitor.config.ts # Capacitor configuration
│ ├── Dockerfile
│ └── package.json
├── docker-compose.yml
└── .env.example
.env)| Variable | Description |
|---|---|
AUTHENTIK_URL |
Authentik instance URL |
AUTHENTIK_CLIENT_ID |
Authentik OAuth client ID |
GOOGLE_CLIENT_ID |
Google OAuth client ID |
GOOGLE_CLIENT_SECRET |
Google OAuth client secret (backend only!) |
vite.config.ts)| Variable | Description |
|---|---|
VITE_BACKEND_URL |
Backend API URL (auto-set by build mode) |
VITE_AUTHENTIK_URL |
Authentik URL (from AUTHENTIK_URL) |
VITE_AUTHENTIK_CLIENT_ID |
Authentik client ID (from AUTHENTIK_CLIENT_ID) |
VITE_AUTHENTIK_REDIRECT_URI |
Callback URL (web or mobile trampoline) |
VITE_GOOGLE_CLIENT_ID |
Google client ID (from GOOGLE_CLIENT_ID) |
VITE_GOOGLE_REDIRECT_URI |
Callback URL (web or mobile trampoline) |
VITE_PLATFORM |
"browser" (default) or "mobile" for Capacitor builds |
| Variable | Description |
|---|---|
VITE_PLATFORM |
Set to "mobile" for Capacitor builds |
PROD_URL |
Production server URL for trampoline redirects |
Key insight: Redirect URIs differ between web and mobile:
http://localhost:3000/callback/{provider}https://your-app.example.com/callback/android/{provider} (trampoline)Clone and configure:
cp .env.example .env
# Edit .env with your OAuth credentials
Configure providers:
Google:
http://localhost:3000/callback/googlehttps://your-app.example.com/callback/android/googleAuthentik:
http://localhost:3000/callback/authentikhttps://your-app.example.com/callback/android/authentikRun (web):
docker compose up
Or locally:
# Backend
cd backend && pip install -r requirements.txt
cd src && uvicorn main:app --reload --port 5000
# Frontend
cd frontend && npm install && npm run dev
Build for mobile (Capacitor):
cd frontend
# Install dependencies
npm install
# Build for mobile (bundles Capacitor plugins)
VITE_PLATFORM=mobile npm run build
# Add Android platform (first time only)
npx cap add android
# Sync web assets to native project
npx cap sync
# Or use the shortcut: npm run cap
# Open in Android Studio
npx cap open android
Open: http://localhost:3000
state parameter to prevent CSRF