OAuth 2.0 Authentication Tutorial

A practical guide to implementing OAuth 2.0 with PKCE using SvelteKit and FastAPI.

Table of Contents


What is OAuth 2.0?

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.


Key Concepts

Authorization Server

The service that authenticates users and issues tokens (e.g., Google, Authentik).

Resource Server

The API that holds protected resources (e.g., Google's user profile API).

Client

Your application requesting access to user resources.

Tokens

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

Scopes

Permissions requested by the client. Examples:

  • openid - Required for OpenID Connect
  • profile - User's name, picture
  • email - User's email address

PKCE Flow Explained

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. It's required for public clients and recommended for all OAuth flows.

Step-by-Step Flow

┌──────────┐                              ┌─────────────────┐
│  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 }│
     │                                             │

Why PKCE Works

  1. code_verifier: Random string generated by client (kept secret)
  2. code_challenge: Hash of verifier sent in authorization request
  3. Verification: Token endpoint requires original verifier that hashes to the challenge

An attacker who intercepts the authorization code cannot exchange it without the original verifier.


Security Architecture

All Token Exchanges via Backend

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     │                              │
   │  <─────────────────────   │                              │

Why Backend-First?

  1. Secrets stay secret: Client secrets never touch the browser
  2. Consistent pattern: Same flow for all providers
  3. Future-proof: Easy to add user registration, rate limiting, etc.
  4. Token validation: Backend can validate tokens before returning

Implementation Guide

1. PKCE Utilities (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(/=+$/, '');
}

2. Start Login (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}`;
}

3. Handle Callback

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
}

4. Provider Configuration

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'
};

5. Backend Token Exchange (handles all providers)

@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

Project Structure

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

Setup

  1. Clone and configure:

    cp .env.example .env
    # Edit .env with your OAuth credentials
    
  2. Configure providers:

    Google:

    Authentik:

    • Create OAuth2 Provider in Authentik admin
    • Add redirect URI: http://localhost:3000/callback/authentik
    • Use public client (PKCE enabled)
  3. Run:

    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
    
  4. Open: http://localhost:3000


Security Checklist

  • Use PKCE for all OAuth flows
  • Validate state parameter to prevent CSRF
  • Store tokens securely (httpOnly cookies or secure storage)
  • Keep client secrets on backend only
  • Use HTTPS in production
  • Validate token signatures (for JWTs)
  • Implement token refresh before expiry

Further Reading

Top categories

Loading Svelte Themes