A practical guide to implementing OAuth 2.0 with PKCE using SvelteKit and FastAPI.
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];
// Generate PKCE values
const codeVerifier = generateRandomString(128);
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateRandomString(32);
// Store for callback verification
sessionStorage.setItem(`pkce_${providerName}`, codeVerifier);
sessionStorage.setItem(`state_${providerName}`, state);
// Build authorization URL
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'
});
// Redirect to authorization server
window.location.href = `${provider.authorizeUrl}?${params}`;
}
export async function handleOAuthCallback(
providerName: AuthProvider,
code: string,
state: string
): Promise<LoginInfo> {
// Verify state to prevent CSRF
const savedState = sessionStorage.getItem(`state_${providerName}`);
const verifier = sessionStorage.getItem(`pkce_${providerName}`);
if (!verifier || state !== savedState) {
throw new Error('Invalid OAuth state');
}
// Exchange code for tokens (method depends on client type)
// ... see full implementation in source
}
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
auth-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 (via backend)
│ │ │ │ ├── pkce.ts # PKCE utilities
│ │ │ │ └── providers/ # Provider configs (public info only)
│ │ │ ├── stores/auth.store.ts # Auth state management
│ │ │ └── types/auth.ts # TypeScript types
│ │ └── routes/
│ │ ├── login/+page.svelte # Login page
│ │ └── callback/ # OAuth callbacks
│ ├── Dockerfile
│ └── package.json
├── docker-compose.yml
└── .env.example
Clone and configure:
cp .env.example .env
# Edit .env with your OAuth credentials
Configure providers:
Google:
http://localhost:3000/callback/googleAuthentik:
http://localhost:3000/callback/authentikRun:
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
Open: http://localhost:3000
state parameter to prevent CSRF