fastapi_oauth 8 Q&As

FastAPI OAuth FAQ & Answers

8 expert FastAPI OAuth answers researched from official documentation. Every answer cites authoritative sources you can verify.

unknown

8 questions
A

Starlette's SessionMiddleware is a middleware component that enables the request.session attribute in FastAPI applications. The middleware must be registered before request.session becomes available; they are not alternatives but rather prerequisite and interface.

Relationship:

  • SessionMiddleware: The middleware that provides session functionality
  • request.session: The API exposed by SessionMiddleware for accessing session data
  • Requirement: SessionMiddleware must be added before routes can use request.session

Setting Up SessionMiddleware:

from fastapi import FastAPI, Request
from starlette.middleware.sessions import SessionMiddleware
import secrets

app = FastAPI()

# Required: Add SessionMiddleware BEFORE defining routes
app.add_middleware(
    SessionMiddleware,
    secret_key=secrets.token_hex(32),  # 64-char hex string (32 bytes)
    session_cookie="session",          # Cookie name (default: "session")
    max_age=1800,                      # Session duration in seconds (30 min)
    same_site="lax",                   # "strict", "lax", or "none"
    https_only=True,                   # Production: True, Dev: False
    path="/"                           # Cookie path scope
)

OAuth Flow with SessionMiddleware:

from authlib.integrations.starlette_client import OAuth
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
import secrets

app = FastAPI()

# Step 1: Add SessionMiddleware (required for OAuth state storage)
app.add_middleware(
    SessionMiddleware,
    secret_key=secrets.token_hex(32)
)

# Step 2: Configure OAuth
oauth = OAuth()
oauth.register(
    name='google',
    client_id='YOUR_CLIENT_ID',
    client_secret='YOUR_CLIENT_SECRET',
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

# Step 3: Login endpoint - stores OAuth state in session
@app.get("/login")
async def login(request: Request):
    redirect_uri = request.url_for('auth_callback')
    
    # Authlib automatically stores OAuth state in request.session
    # State includes CSRF token for security
    return await oauth.google.authorize_redirect(request, redirect_uri)
    # Behind the scenes: request.session['_google_authlib_state_'] = state

# Step 4: Callback endpoint - retrieves OAuth state from session
@app.get("/auth/callback")
async def auth_callback(request: Request):
    try:
        # Authlib verifies state from request.session
        # Raises error if state mismatch (CSRF protection)
        token = await oauth.google.authorize_access_token(request)
        
        user = token.get('userinfo')
        
        # Store user in session for subsequent requests
        request.session['user_id'] = user['sub']
        request.session['email'] = user['email']
        request.session['name'] = user['name']
        
        return RedirectResponse(url='/dashboard')
        
    except Exception as e:
        # State mismatch, token exchange failure, etc.
        return RedirectResponse(url='/login?error=auth_failed')

# Step 5: Protected endpoint - reads user from session
@app.get("/dashboard")
async def dashboard(request: Request):
    user_id = request.session.get('user_id')
    
    if not user_id:
        return RedirectResponse(url='/login')
    
    return {
        "user_id": user_id,
        "email": request.session.get('email'),
        "name": request.session.get('name')
    }

# Step 6: Logout - clears session
@app.get("/logout")
async def logout(request: Request):
    request.session.clear()  # Remove all session data
    return RedirectResponse(url='/')

Session Backend Options:

1. Cookie-Based (Default):

from starlette.middleware.sessions import SessionMiddleware

# Session data stored in encrypted cookie
app.add_middleware(
    SessionMiddleware,
    secret_key=secrets.token_hex(32)
)

# Pros: No server-side storage needed
# Cons: 4KB size limit, sent with every request

2. Server-Side with Redis:

from starlette_session import SessionMiddleware
from starlette_session.backends import BackendType

app.add_middleware(
    SessionMiddleware,
    secret_key=secrets.token_hex(32),
    backend_type=BackendType.redis,
    backend_client=redis.Redis(host='localhost', port=6379)
)

# Pros: No size limit, faster (no cookie decryption), can invalidate server-side
# Cons: Requires Redis infrastructure

How request.session Works:

# request.session is a dict-like interface

# Set values
request.session['key'] = 'value'
request.session['user_id'] = 123
request.session['cart'] = ['item1', 'item2']

# Get values
value = request.session.get('key')  # Returns None if missing
user_id = request.session.get('user_id', default=0)  # With default

# Check existence
if 'user_id' in request.session:
    print("User logged in")

# Delete keys
del request.session['key']

# Clear all
request.session.clear()

# Update multiple
request.session.update({'key1': 'val1', 'key2': 'val2'})

OAuth State Storage (Internal):

Authlib uses session to store OAuth state automatically:

# When authorize_redirect() is called:
request.session['_google_authlib_state_'] = {
    'state': 'random-csrf-token',
    'redirect_uri': 'https://yourapp.com/auth/callback'
}

# When authorize_access_token() is called:
stored_state = request.session.get('_google_authlib_state_')
request_state = request.query_params.get('state')

if stored_state['state'] != request_state:
    raise OAuthError("State mismatch - possible CSRF attack")

# Clean up after successful auth
del request.session['_google_authlib_state_']

Security Considerations:

# 1. Use strong secret key (minimum 32 bytes)
import secrets
secret_key = secrets.token_hex(32)  # DO NOT hardcode in production

# 2. Enable HTTPS-only in production
app.add_middleware(
    SessionMiddleware,
    secret_key=secret_key,
    https_only=True,  # Prevents cookie theft over HTTP
    same_site="lax"   # CSRF protection
)

# 3. Set appropriate session timeout
app.add_middleware(
    SessionMiddleware,
    secret_key=secret_key,
    max_age=1800  # 30 minutes (not days/weeks)
)

# 4. Regenerate session ID after login
@app.post("/login")
async def login(request: Request):
    # Clear old session
    old_data = dict(request.session)
    request.session.clear()
    
    # Authenticate user...
    
    # Create new session with new ID
    request.session['user_id'] = user_id
    # Don't copy old session data blindly

Error Without SessionMiddleware:

app = FastAPI()

# ❌ SessionMiddleware NOT added

@app.get("/login")
async def login(request: Request):
    request.session['user'] = 'data'  # AttributeError!
    # Error: 'Request' object has no attribute 'session'

Version Note: SessionMiddleware available since Starlette 0.12.0, FastAPI 0.38.0+. OAuth state storage pattern standard in Authlib 0.15.0+

99% confidence
A

Authlib's authorize_access_token() method completes the OAuth 2.0 authorization code flow by exchanging the authorization code for an access token. It must be called in the OAuth callback route after the user is redirected back from the provider.

Complete OAuth Flow Pattern:

from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from authlib.integrations.starlette_client import OAuth
from starlette.middleware.sessions import SessionMiddleware
import secrets
import os

app = FastAPI()

# Step 1: Add SessionMiddleware (required for OAuth state storage)
app.add_middleware(
    SessionMiddleware,
    secret_key=os.getenv("SESSION_SECRET", secrets.token_hex(32))
)

# Step 2: Initialize OAuth client
oauth = OAuth()

# Step 3: Register OAuth provider
oauth.register(
    name='google',  # Provider identifier
    client_id=os.getenv('GOOGLE_CLIENT_ID'),
    client_secret=os.getenv('GOOGLE_CLIENT_SECRET'),
    
    # Option A: Use discovery document (recommended)
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    
    # Option B: Manual endpoint configuration
    # access_token_url='https://oauth2.googleapis.com/token',
    # authorize_url='https://accounts.google.com/o/oauth2/auth',
    # userinfo_url='https://openidconnect.googleapis.com/v1/userinfo',
    
    # OAuth scopes
    client_kwargs={
        'scope': 'openid email profile'
    }
)

# Step 4: Login route - initiates OAuth flow
@app.get("/login/google")
async def login_google(request: Request):
    """
    Redirects user to Google's authorization page.
    Stores OAuth state in session for CSRF protection.
    """
    # Build callback URL (must match OAuth provider configuration)
    redirect_uri = request.url_for('auth_google_callback')
    
    # authorize_redirect() does:
    # 1. Generates random state token
    # 2. Stores state in session: request.session['_google_authlib_state_']
    # 3. Redirects to provider's authorization URL with state parameter
    return await oauth.google.authorize_redirect(request, redirect_uri)

# Step 5: Callback route - receives authorization code
@app.get("/auth/google/callback")
async def auth_google_callback(request: Request):
    """
    OAuth provider redirects here with authorization code.
    Exchange code for access token.
    """
    try:
        # authorize_access_token() does:
        # 1. Verifies state parameter matches session (CSRF protection)
        # 2. Exchanges authorization code for access token
        # 3. Optionally fetches user info
        # 4. Returns token dict with access_token, id_token, userinfo
        token = await oauth.google.authorize_access_token(request)
        
        # token structure:
        # {
        #     'access_token': 'ya29.a0AfH6...',
        #     'expires_in': 3599,
        #     'scope': 'openid email profile',
        #     'token_type': 'Bearer',
        #     'id_token': 'eyJhbGciOiJSUzI1NiIs...',
        #     'userinfo': {
        #         'sub': '1234567890',
        #         'email': '[email protected]',
        #         'name': 'John Doe',
        #         'picture': 'https://...'
        #     }
        # }
        
        # Extract user info
        user = token.get('userinfo')
        
        if not user:
            # Fallback: manually fetch userinfo if not included
            user = await oauth.google.userinfo(token=token)
        
        # Store user session
        request.session['user'] = {
            'id': user['sub'],
            'email': user['email'],
            'name': user['name'],
            'picture': user.get('picture')
        }
        
        # Optionally store access token for API calls
        request.session['access_token'] = token['access_token']
        
        # Redirect to application
        return RedirectResponse(url='/dashboard')
        
    except Exception as e:
        # Possible errors:
        # - State mismatch (CSRF attempt)
        # - Invalid authorization code
        # - Token exchange failure
        # - Network issues
        print(f"OAuth error: {e}")
        return RedirectResponse(url='/login?error=oauth_failed')

Multiple OAuth Providers:

# Register multiple providers
oauth.register(
    name='google',
    client_id=os.getenv('GOOGLE_CLIENT_ID'),
    client_secret=os.getenv('GOOGLE_CLIENT_SECRET'),
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

oauth.register(
    name='github',
    client_id=os.getenv('GITHUB_CLIENT_ID'),
    client_secret=os.getenv('GITHUB_CLIENT_SECRET'),
    access_token_url='https://github.com/login/oauth/access_token',
    authorize_url='https://github.com/login/oauth/authorize',
    userinfo_url='https://api.github.com/user',
    client_kwargs={'scope': 'user:email'}
)

# Login routes
@app.get("/login/google")
async def login_google(request: Request):
    redirect_uri = request.url_for('auth_google_callback')
    return await oauth.google.authorize_redirect(request, redirect_uri)

@app.get("/login/github")
async def login_github(request: Request):
    redirect_uri = request.url_for('auth_github_callback')
    return await oauth.github.authorize_redirect(request, redirect_uri)

# Callback routes
@app.get("/auth/google/callback")
async def auth_google_callback(request: Request):
    token = await oauth.google.authorize_access_token(request)
    user = token['userinfo']
    request.session['user'] = user
    return RedirectResponse(url='/dashboard')

@app.get("/auth/github/callback")
async def auth_github_callback(request: Request):
    token = await oauth.github.authorize_access_token(request)
    # GitHub doesn't include userinfo by default
    resp = await oauth.github.get('user', token=token)
    user = resp.json()
    request.session['user'] = user
    return RedirectResponse(url='/dashboard')

Error Handling Pattern:

from authlib.integrations.base_client import OAuthError
from fastapi import HTTPException
import logging

logger = logging.getLogger(__name__)

@app.get("/auth/callback")
async def auth_callback(request: Request):
    try:
        token = await oauth.google.authorize_access_token(request)
        user = token.get('userinfo')
        
        if not user:
            raise HTTPException(status_code=400, detail="Failed to fetch user info")
        
        request.session['user'] = user
        return RedirectResponse(url='/dashboard')
        
    except OAuthError as e:
        # OAuth-specific errors (state mismatch, invalid code, etc.)
        logger.error(f"OAuth error: {e.error} - {e.description}")
        return RedirectResponse(url=f'/login?error={e.error}')
        
    except HTTPException as e:
        # Application errors
        logger.error(f"Application error: {e.detail}")
        return RedirectResponse(url='/login?error=app_error')
        
    except Exception as e:
        # Unexpected errors
        logger.exception(f"Unexpected error during OAuth: {e}")
        return RedirectResponse(url='/login?error=unknown')

Token Storage and Refresh:

import time

@app.get("/auth/callback")
async def auth_callback(request: Request):
    token = await oauth.google.authorize_access_token(request)
    
    # Store complete token for API calls and refresh
    request.session['oauth_token'] = {
        'access_token': token['access_token'],
        'refresh_token': token.get('refresh_token'),  # Not all providers give this
        'expires_at': time.time() + token['expires_in'],
        'token_type': token['token_type']
    }
    
    user = token['userinfo']
    request.session['user'] = user
    
    return RedirectResponse(url='/dashboard')

# Using stored token for API calls
@app.get("/api/user/calendar")
async def get_calendar(request: Request):
    oauth_token = request.session.get('oauth_token')
    
    if not oauth_token:
        raise HTTPException(status_code=401, detail="Not authenticated")
    
    # Check if token expired
    if time.time() > oauth_token['expires_at']:
        # Refresh token (if refresh_token available)
        if oauth_token.get('refresh_token'):
            new_token = await oauth.google.fetch_access_token(
                refresh_token=oauth_token['refresh_token']
            )
            request.session['oauth_token'] = {
                'access_token': new_token['access_token'],
                'refresh_token': new_token.get('refresh_token', oauth_token['refresh_token']),
                'expires_at': time.time() + new_token['expires_in'],
                'token_type': new_token['token_type']
            }
            oauth_token = request.session['oauth_token']
        else:
            # No refresh token, user must re-authenticate
            return RedirectResponse(url='/login')
    
    # Make API call with access token
    resp = await oauth.google.get(
        'https://www.googleapis.com/calendar/v3/calendars/primary/events',
        token=oauth_token
    )
    
    return resp.json()

Security Best Practices:

  1. Validate Redirect URI: Must exactly match registered URI in OAuth provider
  2. Use HTTPS in Production: https_only=True for SessionMiddleware
  3. Short Session Lifetime: max_age=1800 (30 minutes)
  4. Validate State: Authlib handles this automatically
  5. Don't Log Tokens: Sensitive data, exclude from logs
  6. Store Minimal Data: Only store necessary user info in session

Version Note: Authlib 0.15.0+ for Starlette integration, FastAPI 0.68.0+ recommended

99% confidence
A

Production OAuth callback handlers must handle multiple error scenarios: state mismatches (CSRF attacks), token exchange failures, network issues, and missing user data. Proper error handling includes logging for debugging while providing user-friendly messages and avoiding sensitive data exposure.

Comprehensive Error Handling Pattern:

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import RedirectResponse
from authlib.integrations.starlette_client import OAuth
from authlib.integrations.base_client import OAuthError
from starlette.middleware.sessions import SessionMiddleware
import logging
import secrets
import os

app = FastAPI()

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Separate logger for security events
security_logger = logging.getLogger('security')
security_handler = logging.FileHandler('/var/log/oauth_security.log')
security_logger.addHandler(security_handler)
security_logger.setLevel(logging.WARNING)

app.add_middleware(
    SessionMiddleware,
    secret_key=os.getenv("SESSION_SECRET", secrets.token_hex(32))
)

oauth = OAuth()
oauth.register(
    name='google',
    client_id=os.getenv('GOOGLE_CLIENT_ID'),
    client_secret=os.getenv('GOOGLE_CLIENT_SECRET'),
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

@app.get("/auth/google/callback")
async def auth_google_callback(request: Request):
    """
    OAuth callback with comprehensive error handling.
    """
    
    # Step 1: Validate OAuth error response from provider
    error = request.query_params.get('error')
    if error:
        error_description = request.query_params.get('error_description', 'Unknown error')
        
        # Log security event (user denied, invalid request, etc.)
        security_logger.warning(
            f"OAuth provider error: {error} - {error_description} "
            f"| IP: {request.client.host} | Session: {request.session.get('_id', 'unknown')}"
        )
        
        # User-friendly error messages
        error_messages = {
            'access_denied': 'You declined to authorize the application.',
            'invalid_request': 'The authentication request was invalid. Please try again.',
            'unauthorized_client': 'This application is not authorized.',
            'unsupported_response_type': 'Authentication configuration error.',
            'invalid_scope': 'Requested permissions are invalid.',
            'server_error': 'Authentication service is temporarily unavailable.',
            'temporarily_unavailable': 'Authentication service is temporarily unavailable.'
        }
        
        user_message = error_messages.get(error, 'Authentication failed. Please try again.')
        
        return RedirectResponse(
            url=f"/login?error={error}&message={user_message}",
            status_code=303
        )
    
    # Step 2: Check for required parameters
    code = request.query_params.get('code')
    state = request.query_params.get('state')
    
    if not code or not state:
        security_logger.warning(
            f"Missing OAuth parameters | code: {bool(code)} | state: {bool(state)} "
            f"| IP: {request.client.host}"
        )
        return RedirectResponse(
            url="/login?error=invalid_callback&message=Invalid authentication response.",
            status_code=303
        )
    
    # Step 3: Token exchange with comprehensive error handling
    try:
        # This validates state automatically and exchanges code for token
        token = await oauth.google.authorize_access_token(request)
        
    except OAuthError as e:
        # OAuth-specific errors (most common)
        error_type = e.error
        error_desc = e.description or 'Unknown OAuth error'
        
        # Classify error severity
        if error_type in ['invalid_grant', 'invalid_code']:
            # Likely expired/reused code (normal after refresh)
            logger.info(f"OAuth token exchange failed: {error_type} - {error_desc}")
            user_message = "Authentication expired. Please try again."
            
        elif error_type == 'invalid_client':
            # Configuration error (critical)
            logger.error(f"OAuth client configuration error: {error_desc}")
            security_logger.error(f"CRITICAL: OAuth client configuration error: {error_desc}")
            user_message = "Authentication service configuration error."
            
        elif 'state' in error_desc.lower():
            # State mismatch - potential CSRF attack
            security_logger.warning(
                f"POSSIBLE CSRF: State mismatch | IP: {request.client.host} | "
                f"Error: {error_desc}"
            )
            user_message = "Authentication security check failed. Please try again."
            
        else:
            # Other OAuth errors
            logger.warning(f"OAuth error: {error_type} - {error_desc}")
            user_message = "Authentication failed. Please try again."
        
        return RedirectResponse(
            url=f"/login?error={error_type}&message={user_message}",
            status_code=303
        )
    
    except Exception as e:
        # Network errors, timeouts, etc.
        logger.exception(f"Unexpected error during token exchange: {e}")
        return RedirectResponse(
            url="/login?error=system_error&message=Authentication system error. Please try again.",
            status_code=303
        )
    
    # Step 4: Validate and extract user info
    try:
        user = token.get('userinfo')
        
        if not user:
            # Userinfo not in token, fetch manually
            try:
                user = await oauth.google.userinfo(token=token)
            except Exception as e:
                logger.error(f"Failed to fetch userinfo: {e}")
                return RedirectResponse(
                    url="/login?error=userinfo_failed&message=Failed to retrieve user information.",
                    status_code=303
                )
        
        # Validate required claims
        required_claims = ['sub', 'email']
        missing_claims = [claim for claim in required_claims if claim not in user]
        
        if missing_claims:
            logger.error(
                f"Missing required claims: {missing_claims} | "
                f"Available claims: {list(user.keys())}"
            )
            return RedirectResponse(
                url="/login?error=incomplete_profile&message=User profile is incomplete.",
                status_code=303
            )
        
        # Optional: Verify email if required
        if not user.get('email_verified', True):  # Google always provides verified
            logger.warning(f"Unverified email login attempt: {user.get('email')}")
            return RedirectResponse(
                url="/login?error=email_not_verified&message=Please verify your email address.",
                status_code=303
            )
        
    except Exception as e:
        logger.exception(f"Error processing user info: {e}")
        return RedirectResponse(
            url="/login?error=processing_error&message=Failed to process user information.",
            status_code=303
        )
    
    # Step 5: Store user session (database operations with error handling)
    try:
        # Save/update user in database
        user_id = await save_or_update_user(
            email=user['email'],
            name=user.get('name', ''),
            picture=user.get('picture', ''),
            provider='google',
            provider_id=user['sub']
        )
        
        # Create session
        request.session['user_id'] = user_id
        request.session['email'] = user['email']
        request.session['name'] = user.get('name', '')
        
        # Optional: Store access token for API calls
        # ⚠️ Security: Only if needed, consider encrypting
        # request.session['access_token'] = token['access_token']
        
        # Log successful authentication
        logger.info(
            f"Successful OAuth login: {user['email']} | "
            f"Provider: google | IP: {request.client.host}"
        )
        
        return RedirectResponse(url='/dashboard', status_code=303)
        
    except Exception as e:
        # Database errors, session errors
        logger.exception(f"Error saving user session: {e}")
        
        # Clear any partial session data
        request.session.clear()
        
        return RedirectResponse(
            url="/login?error=session_error&message=Failed to create session. Please try again.",
            status_code=303
        )

# Helper function for database operations
async def save_or_update_user(
    email: str,
    name: str,
    picture: str,
    provider: str,
    provider_id: str
) -> int:
    """
    Save or update user in database.
    Returns user_id.
    """
    # Implementation depends on your database
    # Example with asyncpg:
    async with db_pool.acquire() as conn:
        user_id = await conn.fetchval(
            """
            INSERT INTO users (email, name, picture, provider, provider_id, last_login)
            VALUES ($1, $2, $3, $4, $5, NOW())
            ON CONFLICT (email)
            DO UPDATE SET
                name = EXCLUDED.name,
                picture = EXCLUDED.picture,
                last_login = NOW()
            RETURNING id
            """,
            email, name, picture, provider, provider_id
        )
    return user_id

Logging Best Practices:

# ✅ GOOD: Log errors without sensitive data
logger.error(f"Token exchange failed for user email domain: {user_email.split('@')[1]}")

# ❌ BAD: Don't log tokens or full user data
logger.error(f"Token exchange failed: {token}")  # Exposes access token!
logger.error(f"User data: {user}")  # May contain sensitive info

# ✅ GOOD: Log error types and sanitized context
logger.error(
    f"OAuth error | Type: {error_type} | "
    f"IP: {request.client.host} | "
    f"Session ID: {request.session.get('_id')[:8]}..."  # Partial session ID
)

User-Friendly Error Display:

<!-- login.html -->
<div class="error" *ngIf="error">
  <p>{{ errorMessage }}</p>
  <a href="/login/google">Try Again</a>
</div>

Monitoring and Alerts:

# Send alerts for critical errors
if error_type == 'invalid_client':
    await send_alert(
        severity='critical',
        message='OAuth client configuration error detected',
        details=error_desc
    )

Version Note: Error handling patterns apply to Authlib 0.15.0+, FastAPI 0.68.0+

99% confidence
A

The server_metadata_url points to an OpenID Connect discovery document that automatically configures all OAuth endpoints, eliminating the need for manual endpoint URLs. It's the recommended approach for OAuth 2.0/OpenID Connect providers that support auto-discovery.

Discovery Document (server_metadata_url):

from authlib.integrations.starlette_client import OAuth

oauth = OAuth()

# ✅ RECOMMENDED: Auto-discovery via server_metadata_url
oauth.register(
    name='google',
    client_id='YOUR_CLIENT_ID',
    client_secret='YOUR_CLIENT_SECRET',
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

# Authlib automatically fetches and configures:
# - authorization_endpoint
# - token_endpoint
# - userinfo_endpoint
# - jwks_uri
# - issuer
# - supported scopes, response types, etc.

What's in the Discovery Document:

# Fetch Google's discovery document
curl https://accounts.google.com/.well-known/openid-configuration
{
  "issuer": "https://accounts.google.com",
  "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
  "token_endpoint": "https://oauth2.googleapis.com/token",
  "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
  "revocation_endpoint": "https://oauth2.googleapis.com/revoke",
  "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
  "response_types_supported": ["code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid", "email", "profile"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
  "claims_supported": ["aud", "email", "email_verified", "exp", "family_name", "given_name", "iat", "iss", "locale", "name", "picture", "sub"],
  "code_challenge_methods_supported": ["plain", "S256"]
}

Manual Endpoint Configuration (Alternative):

# ❌ MANUAL: Specify all endpoints explicitly (not recommended)
oauth.register(
    name='google',
    client_id='YOUR_CLIENT_ID',
    client_secret='YOUR_CLIENT_SECRET',
    
    # Must specify each endpoint
    access_token_url='https://oauth2.googleapis.com/token',
    authorize_url='https://accounts.google.com/o/oauth2/v2/auth',
    userinfo_url='https://openidconnect.googleapis.com/v1/userinfo',
    
    # Optional: JWKS for token validation
    jwks_uri='https://www.googleapis.com/oauth2/v3/certs',
    
    client_kwargs={'scope': 'openid email profile'}
)

When to Use Each Approach:

Scenario Use server_metadata_url Use Manual Endpoints
OpenID Connect Provider ✅ Yes (recommended) ❌ Not needed
OAuth 2.0 Only (no OIDC) ❌ Not available ✅ Required
Provider has Discovery ✅ Yes ❌ Maintenance burden
Custom OAuth Server ❌ May not support ✅ If no discovery
Production Reliability ✅ Auto-updates endpoints ❌ Hardcoded URLs
Endpoint URLs Change ✅ Auto-adapts ❌ Breaks until updated

Providers with Discovery Support:

# Google (OpenID Connect)
oauth.register(
    name='google',
    client_id='...',
    client_secret='...',
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

# Microsoft Azure AD (OpenID Connect)
oauth.register(
    name='azure',
    client_id='...',
    client_secret='...',
    server_metadata_url='https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

# Okta (OpenID Connect)
oauth.register(
    name='okta',
    client_id='...',
    client_secret='...',
    server_metadata_url='https://YOUR_DOMAIN.okta.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

# Auth0 (OpenID Connect)
oauth.register(
    name='auth0',
    client_id='...',
    client_secret='...',
    server_metadata_url='https://YOUR_DOMAIN.auth0.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

Providers Requiring Manual Configuration:

# GitHub (OAuth 2.0 only, no OpenID Connect)
oauth.register(
    name='github',
    client_id='...',
    client_secret='...',
    access_token_url='https://github.com/login/oauth/access_token',
    authorize_url='https://github.com/login/oauth/authorize',
    userinfo_url='https://api.github.com/user',
    client_kwargs={'scope': 'user:email'}
)

# Discord (OAuth 2.0 only)
oauth.register(
    name='discord',
    client_id='...',
    client_secret='...',
    access_token_url='https://discord.com/api/oauth2/token',
    authorize_url='https://discord.com/api/oauth2/authorize',
    userinfo_url='https://discord.com/api/users/@me',
    client_kwargs={'scope': 'identify email'}
)

Benefits of Auto-Discovery:

  1. Endpoint Reliability: URLs automatically updated if provider changes them
  2. Less Code: No need to specify individual endpoints
  3. Security: Automatically gets latest security configurations (JWKS, algorithms)
  4. OpenID Compliance: Ensures proper OIDC implementation
  5. Maintenance: Reduces configuration drift and manual updates

Discovery Document Caching:

# Authlib caches discovery document automatically
# First request: Fetches from server_metadata_url
# Subsequent requests: Uses cached metadata
# Cache duration: Default 3600 seconds (1 hour)

# Custom cache duration (if needed):
from authlib.integrations.starlette_client import OAuth
from authlib.oauth2.rfc8414 import AuthorizationServerMetadata

oauth = OAuth(
    cache=cache_instance,  # Optional: provide custom cache
    fetch_token=fetch_token_func  # Optional: custom token fetch
)

Debugging Discovery Issues:

import logging

# Enable Authlib debug logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger('authlib')
logger.setLevel(logging.DEBUG)

# Check what endpoints Authlib loaded
@app.get("/debug/oauth-config")
async def debug_oauth():
    metadata = oauth.google.load_server_metadata()
    return {
        "authorization_endpoint": metadata.get('authorization_endpoint'),
        "token_endpoint": metadata.get('token_endpoint'),
        "userinfo_endpoint": metadata.get('userinfo_endpoint'),
        "issuer": metadata.get('issuer')
    }

Fallback Pattern:

# Attempt auto-discovery, fall back to manual if fails
try:
    oauth.register(
        name='google',
        client_id='...',
        client_secret='...',
        server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
        client_kwargs={'scope': 'openid email profile'}
    )
except Exception as e:
    logger.warning(f"Auto-discovery failed, using manual endpoints: {e}")
    oauth.register(
        name='google',
        client_id='...',
        client_secret='...',
        access_token_url='https://oauth2.googleapis.com/token',
        authorize_url='https://accounts.google.com/o/oauth2/v2/auth',
        userinfo_url='https://openidconnect.googleapis.com/v1/userinfo',
        client_kwargs={'scope': 'openid email profile'}
    )

When Manual Config is Better:

  1. Custom OAuth Server: Your own OAuth implementation without discovery
  2. Strict Version Control: Pin exact endpoint versions for stability
  3. Offline Development: Pre-configured endpoints work without internet
  4. Legacy Providers: OAuth 2.0 servers without OpenID Connect support

OpenID Connect Discovery Spec: RFC 8414 (OAuth 2.0 Authorization Server Metadata) and OpenID Connect Discovery 1.0

Version Note: server_metadata_url support since Authlib 0.14.0+, standard in OpenID Connect 1.0

99% confidence
A

Authlib's OAuth methods for Starlette/FastAPI require the Request object as the first parameter. This is necessary because Authlib uses the request to access session data (for state storage), build redirect URLs, and extract query parameters.

Core Pattern:

from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from authlib.integrations.starlette_client import OAuth
from starlette.middleware.sessions import SessionMiddleware
import secrets

app = FastAPI()

app.add_middleware(
    SessionMiddleware,
    secret_key=secrets.token_hex(32)
)

oauth = OAuth()
oauth.register(
    name='google',
    client_id='YOUR_CLIENT_ID',
    client_secret='YOUR_CLIENT_SECRET',
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

# Pattern 1: authorize_redirect() - Requires Request
@app.get("/login")
async def login(request: Request):  # ← Request parameter required
    redirect_uri = request.url_for('auth_callback')
    
    # ✅ CORRECT: Pass request as first argument
    return await oauth.google.authorize_redirect(request, redirect_uri)
    
    # ❌ WRONG: Missing request parameter
    # return await oauth.google.authorize_redirect(redirect_uri)
    # Error: authorize_redirect() missing required positional argument: 'request'

# Pattern 2: authorize_access_token() - Requires Request
@app.get("/auth/callback")
async def auth_callback(request: Request):  # ← Request parameter required
    
    # ✅ CORRECT: Pass request as first (and only) argument
    token = await oauth.google.authorize_access_token(request)
    
    # ❌ WRONG: No request parameter
    # token = await oauth.google.authorize_access_token()
    # Error: authorize_access_token() missing required positional argument: 'request'
    
    user = token.get('userinfo')
    request.session['user'] = user
    
    return RedirectResponse(url='/dashboard')

Why Request is Required:

  1. Session Access: Authlib stores/retrieves OAuth state in request.session
  2. URL Building: Needs request.url_for() and request.base_url for redirects
  3. Query Parameters: Extracts code, state, error from request.query_params
  4. Headers: May access request.headers for additional context

What Authlib Does with Request:

# Inside authorize_redirect():
async def authorize_redirect(self, request, redirect_uri, **kwargs):
    # 1. Generate and store state in session
    state = generate_token()
    request.session[f'_{self.name}_authlib_state_'] = state
    
    # 2. Build authorization URL with state
    url = self.create_authorization_url(
        redirect_uri=redirect_uri,
        state=state,
        **kwargs
    )
    
    # 3. Return redirect response
    return RedirectResponse(url=url)

# Inside authorize_access_token():
async def authorize_access_token(self, request, **kwargs):
    # 1. Retrieve state from session
    stored_state = request.session.get(f'_{self.name}_authlib_state_')
    
    # 2. Get state and code from query parameters
    params = dict(request.query_params)
    request_state = params.get('state')
    auth_code = params.get('code')
    
    # 3. Verify state matches (CSRF protection)
    if stored_state != request_state:
        raise OAuthError('State mismatch')
    
    # 4. Exchange code for token
    token = await self.fetch_access_token(
        code=auth_code,
        redirect_uri=str(request.base_url) + 'auth/callback',
        **kwargs
    )
    
    # 5. Clean up session
    del request.session[f'_{self.name}_authlib_state_']
    
    return token

Complete OAuth Flow with Request Handling:

from fastapi import FastAPI, Request, Depends
from fastapi.responses import RedirectResponse, HTMLResponse
from authlib.integrations.starlette_client import OAuth
from starlette.middleware.sessions import SessionMiddleware
import secrets

app = FastAPI()

app.add_middleware(
    SessionMiddleware,
    secret_key=secrets.token_hex(32)
)

oauth = OAuth()
oauth.register(
    name='google',
    client_id='YOUR_CLIENT_ID',
    client_secret='YOUR_CLIENT_SECRET',
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

# Step 1: Login page
@app.get("/login")
async def login_page():
    return HTMLResponse("""
    <html>
        <body>
            <h1>Login</h1>
            <a href="/login/google">Login with Google</a>
        </body>
    </html>
    """)

# Step 2: Initiate OAuth flow (requires Request)
@app.get("/login/google")
async def login_google(request: Request):
    # Build callback URL using request.url_for()
    redirect_uri = request.url_for('auth_google_callback')
    
    # authorize_redirect() requires request for session access
    return await oauth.google.authorize_redirect(request, redirect_uri)

# Step 3: OAuth callback (requires Request)
@app.get("/auth/google/callback")
async def auth_google_callback(request: Request):
    # authorize_access_token() requires request to:
    # - Get query params (code, state)
    # - Validate state from session
    # - Exchange code for token
    token = await oauth.google.authorize_access_token(request)
    
    user = token.get('userinfo')
    
    # Store user in session (request.session)
    request.session['user'] = {
        'id': user['sub'],
        'email': user['email'],
        'name': user['name']
    }
    
    return RedirectResponse(url='/dashboard')

# Step 4: Protected route using session
@app.get("/dashboard")
async def dashboard(request: Request):
    user = request.session.get('user')
    
    if not user:
        return RedirectResponse(url='/login')
    
    return {
        "message": f"Welcome {user['name']}",
        "user": user
    }

# Step 5: Logout
@app.get("/logout")
async def logout(request: Request):
    request.session.clear()
    return RedirectResponse(url='/')

Using Dependencies (Still Requires Request):

from fastapi import Depends

# Dependency to get current user from session
async def get_current_user(request: Request):
    user = request.session.get('user')
    if not user:
        raise HTTPException(status_code=401, detail="Not authenticated")
    return user

# Using dependency in route (Request still needed)
@app.get("/profile")
async def profile(
    request: Request,  # ← Still need Request for session access
    user: dict = Depends(get_current_user)
):
    return {
        "user": user,
        "session_id": request.session.get('_id', 'unknown')
    }

API Methods Requiring Request:

# All these methods require Request as first parameter:

# 1. Start OAuth flow
await oauth.google.authorize_redirect(request, redirect_uri)

# 2. Complete OAuth flow
await oauth.google.authorize_access_token(request)

# 3. Parse ID token (if not auto-included)
await oauth.google.parse_id_token(request, token)

# 4. Fetch userinfo (if not in token)
await oauth.google.userinfo(request=request, token=token)

# Note: Some methods can work without request if you pass token explicitly:
await oauth.google.get('https://api.example.com/data', token=token_dict)

Common Mistakes:

# ❌ WRONG: Trying to use OAuth without Request
@app.get("/login")
async def login():  # Missing Request parameter
    return await oauth.google.authorize_redirect("/auth/callback")
    # Error: authorize_redirect() missing required argument: 'request'

# ❌ WRONG: Passing redirect_uri as first parameter
@app.get("/login")
async def login(request: Request):
    return await oauth.google.authorize_redirect(
        "/auth/callback",  # ← WRONG position
        request             # ← WRONG position
    )
    # Error: 'str' object has no attribute 'session'

# ✅ CORRECT: Request first, then redirect_uri
@app.get("/login")
async def login(request: Request):
    return await oauth.google.authorize_redirect(
        request,            # ← First parameter
        "/auth/callback"    # ← Second parameter
    )

Type Hints for Clarity:

from starlette.requests import Request  # More specific import
from authlib.integrations.starlette_client import StarletteOAuth2App

@app.get("/login")
async def login(request: Request) -> RedirectResponse:
    redirect_uri: str = request.url_for('auth_callback')
    return await oauth.google.authorize_redirect(request, redirect_uri)

@app.get("/auth/callback")
async def auth_callback(request: Request) -> RedirectResponse:
    token: dict = await oauth.google.authorize_access_token(request)
    user: dict = token.get('userinfo', {})
    request.session['user'] = user
    return RedirectResponse(url='/dashboard')

Version Note: Request parameter requirement consistent since Authlib 0.15.0 (Starlette integration)

99% confidence
A

When token.get('userinfo') returns None, you must manually fetch user information using the provider's userinfo endpoint or parse the ID token (for OpenID Connect). This happens when the OAuth provider doesn't include userinfo in the token response by default.

Why userinfo Might Be None:

  1. Provider Configuration: Some providers require explicit userinfo fetch
  2. Scope Limitations: Missing openid scope may exclude userinfo
  3. Token Type: Access token response doesn't include user claims
  4. Provider Behavior: GitHub, Discord, and others require separate API call

Pattern 1: Fetch Userinfo Manually (Recommended)

from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from authlib.integrations.starlette_client import OAuth
from starlette.middleware.sessions import SessionMiddleware
import secrets

app = FastAPI()

app.add_middleware(
    SessionMiddleware,
    secret_key=secrets.token_hex(32)
)

oauth = OAuth()
oauth.register(
    name='google',
    client_id='YOUR_CLIENT_ID',
    client_secret='YOUR_CLIENT_SECRET',
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

@app.get("/auth/callback")
async def auth_callback(request: Request):
    try:
        # Exchange code for token
        token = await oauth.google.authorize_access_token(request)
        
        # Try to get userinfo from token
        user = token.get('userinfo')
        
        if not user:
            # Option 1: Fetch from userinfo endpoint
            user = await oauth.google.userinfo(token=token)
            
            # OR Option 2: Parse ID token (if OpenID Connect)
            # user = await oauth.google.parse_id_token(request, token)
        
        # Now user contains profile data
        request.session['user'] = {
            'id': user['sub'],
            'email': user['email'],
            'name': user.get('name', ''),
            'picture': user.get('picture', '')
        }
        
        return RedirectResponse(url='/dashboard')
        
    except Exception as e:
        print(f"Error fetching user info: {e}")
        return RedirectResponse(url='/login?error=userinfo_failed')

Pattern 2: Parse ID Token (OpenID Connect)

@app.get("/auth/callback")
async def auth_callback(request: Request):
    token = await oauth.google.authorize_access_token(request)
    
    # Check token structure
    user = token.get('userinfo')
    
    if not user and 'id_token' in token:
        # Parse ID token to extract claims
        user = await oauth.google.parse_id_token(request, token)
        
        # ID token claims:
        # {
        #     'sub': '1234567890',
        #     'email': '[email protected]',
        #     'email_verified': True,
        #     'name': 'John Doe',
        #     'picture': 'https://...',
        #     'iat': 1234567890,
        #     'exp': 1234571490
        # }
    
    if not user:
        # Last resort: Manual API call
        user = await oauth.google.userinfo(token=token)
    
    request.session['user'] = user
    return RedirectResponse(url='/dashboard')

Pattern 3: Comprehensive Fallback Chain

from authlib.integrations.base_client import OAuthError
import logging

logger = logging.getLogger(__name__)

@app.get("/auth/callback")
async def auth_callback(request: Request):
    try:
        token = await oauth.google.authorize_access_token(request)
        user = None
        
        # Method 1: Check if userinfo included in token response
        if 'userinfo' in token:
            user = token['userinfo']
            logger.info("User info from token response")
        
        # Method 2: Parse ID token (OpenID Connect)
        elif 'id_token' in token:
            try:
                user = await oauth.google.parse_id_token(request, token)
                logger.info("User info from ID token")
            except Exception as e:
                logger.warning(f"Failed to parse ID token: {e}")
        
        # Method 3: Fetch from userinfo endpoint
        if not user:
            try:
                user = await oauth.google.userinfo(token=token)
                logger.info("User info from userinfo endpoint")
            except OAuthError as e:
                logger.error(f"Failed to fetch userinfo: {e}")
                return RedirectResponse(
                    url='/login?error=userinfo_unavailable'
                )
        
        # Validate required fields
        if not user or 'sub' not in user:
            logger.error(f"Invalid user data: {user}")
            return RedirectResponse(
                url='/login?error=invalid_userinfo'
            )
        
        # Store user session
        request.session['user'] = {
            'id': user['sub'],
            'email': user.get('email', ''),
            'name': user.get('name', 'Unknown'),
            'picture': user.get('picture', ''),
            'email_verified': user.get('email_verified', False)
        }
        
        return RedirectResponse(url='/dashboard')
        
    except Exception as e:
        logger.exception(f"OAuth callback error: {e}")
        return RedirectResponse(url='/login?error=auth_failed')

Provider-Specific Patterns:

# GitHub (no userinfo in token, requires API call)
oauth.register(
    name='github',
    client_id='...',
    client_secret='...',
    access_token_url='https://github.com/login/oauth/access_token',
    authorize_url='https://github.com/login/oauth/authorize',
    userinfo_url='https://api.github.com/user',
    client_kwargs={'scope': 'user:email'}
)

@app.get("/auth/github/callback")
async def auth_github_callback(request: Request):
    token = await oauth.github.authorize_access_token(request)
    
    # GitHub NEVER includes userinfo in token
    # Must call userinfo endpoint
    user = await oauth.github.userinfo(token=token)
    
    # GitHub response:
    # {
    #     'login': 'username',
    #     'id': 12345,
    #     'email': '[email protected]',
    #     'name': 'Full Name',
    #     'avatar_url': 'https://...'
    # }
    
    request.session['user'] = {
        'id': str(user['id']),
        'username': user['login'],
        'email': user.get('email', ''),
        'name': user.get('name', user['login'])
    }
    
    return RedirectResponse(url='/dashboard')

# Discord (similar to GitHub)
oauth.register(
    name='discord',
    client_id='...',
    client_secret='...',
    access_token_url='https://discord.com/api/oauth2/token',
    authorize_url='https://discord.com/api/oauth2/authorize',
    userinfo_url='https://discord.com/api/users/@me',
    client_kwargs={'scope': 'identify email'}
)

@app.get("/auth/discord/callback")
async def auth_discord_callback(request: Request):
    token = await oauth.discord.authorize_access_token(request)
    
    # Discord requires manual userinfo fetch
    user = await oauth.discord.userinfo(token=token)
    
    request.session['user'] = {
        'id': user['id'],
        'username': user['username'],
        'discriminator': user['discriminator'],
        'email': user.get('email', ''),
        'avatar': f"https://cdn.discordapp.com/avatars/{user['id']}/{user['avatar']}.png"
    }
    
    return RedirectResponse(url='/dashboard')

Manual HTTP Request (Last Resort)

import aiohttp

@app.get("/auth/callback")
async def auth_callback(request: Request):
    token = await oauth.google.authorize_access_token(request)
    
    user = token.get('userinfo')
    
    if not user:
        # Manual HTTP request to userinfo endpoint
        async with aiohttp.ClientSession() as session:
            headers = {
                'Authorization': f"Bearer {token['access_token']}"
            }
            async with session.get(
                'https://openidconnect.googleapis.com/v1/userinfo',
                headers=headers
            ) as response:
                if response.status == 200:
                    user = await response.json()
                else:
                    raise Exception(f"Userinfo request failed: {response.status}")
    
    request.session['user'] = user
    return RedirectResponse(url='/dashboard')

Debugging Userinfo Issues:

import json

@app.get("/auth/callback")
async def auth_callback(request: Request):
    token = await oauth.google.authorize_access_token(request)
    
    # Debug: Print token structure
    print("Token keys:", token.keys())
    print("Token:", json.dumps(token, indent=2, default=str))
    
    # Check each field
    print("Has userinfo:", 'userinfo' in token)
    print("Has id_token:", 'id_token' in token)
    print("Has access_token:", 'access_token' in token)
    
    user = token.get('userinfo')
    
    if not user:
        print("Fetching userinfo manually...")
        user = await oauth.google.userinfo(token=token)
        print("Userinfo:", json.dumps(user, indent=2))
    
    request.session['user'] = user
    return RedirectResponse(url='/dashboard')

Best Practices:

  1. ✅ Always check if userinfo exists before using
  2. ✅ Have fallback methods (ID token → userinfo endpoint)
  3. ✅ Validate required fields (sub, email) exist
  4. ✅ Handle network failures gracefully
  5. ✅ Log which method succeeded for debugging
  6. ❌ Don't assume userinfo is always present
  7. ❌ Don't make multiple redundant API calls

Version Note: userinfo handling patterns consistent since Authlib 0.15.0+, FastAPI 0.68.0+

99% confidence
A

Python's python-dotenv library loads environment variables from a .env file into os.environ. It must be called before importing modules that read environment variables, typically at the very top of your application's entry point (main.py, app.py, or init.py).

Basic Usage:

# main.py - Application entry point
from dotenv import load_dotenv
import os

# ✅ CORRECT: Load .env BEFORE importing app modules
load_dotenv()  # Loads .env from current directory

# Now environment variables are available
DATABASE_URL = os.getenv('DATABASE_URL')
SECRET_KEY = os.getenv('SECRET_KEY')

# Import app after loading env vars
from app import create_app

app = create_app()

if __name__ == "__main__":
    app.run()

When to Call load_dotenv():

# ✅ CORRECT: At the very top of entry point
# main.py
from dotenv import load_dotenv
load_dotenv()  # First thing after imports

import os
from fastapi import FastAPI
from config import settings  # This module reads env vars

app = FastAPI()

# ❌ WRONG: After imports that use env vars
# main.py
import os
from fastapi import FastAPI
from config import settings  # Tries to read env vars but .env not loaded yet!

from dotenv import load_dotenv
load_dotenv()  # Too late! settings already initialized

app = FastAPI()

FastAPI Application Pattern:

# main.py
from dotenv import load_dotenv

# Load environment variables FIRST
load_dotenv()

import os
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from authlib.integrations.starlette_client import OAuth

app = FastAPI()

# Now os.getenv() will find variables from .env
app.add_middleware(
    SessionMiddleware,
    secret_key=os.getenv('SESSION_SECRET', 'default-secret')
)

oauth = OAuth()
oauth.register(
    name='google',
    client_id=os.getenv('GOOGLE_CLIENT_ID'),
    client_secret=os.getenv('GOOGLE_CLIENT_SECRET'),
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

.env File Format:

# .env (in project root)
DATABASE_URL=postgresql://user:pass@localhost/dbname
SECRET_KEY=your-secret-key-here
GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=secret
ENVIRONMENT=development
DEBUG=True

# Comments are allowed
# Quotes are optional but recommended for values with spaces
API_KEY="key with spaces"

# Variable expansion not supported by default
# Use dotenv_values() with interpolate=True if needed
BASE_URL=https://example.com
API_URL=${BASE_URL}/api  # Won't work without interpolate=True

Advanced Usage:

from dotenv import load_dotenv
import os

# Specify custom .env file path
load_dotenv(dotenv_path='/path/to/.env')

# Load from specific file (e.g., .env.production)
load_dotenv('.env.production')

# Override existing environment variables
load_dotenv(override=True)  # Default: False (doesn't override existing)

# Load without overriding shell environment
load_dotenv(override=False)  # Respects already-set env vars

# Verbose mode for debugging
load_dotenv(verbose=True)  # Prints loaded variables

# Example .env file location logic
import pathlib

env_path = pathlib.Path('.') / '.env'
load_dotenv(dotenv_path=env_path)

Environment-Specific Loading:

from dotenv import load_dotenv
import os

# Load base .env
load_dotenv('.env')

# Override with environment-specific file
env = os.getenv('ENVIRONMENT', 'development')
load_dotenv(f'.env.{env}', override=True)

# Example file structure:
# .env              (base config)
# .env.development  (dev overrides)
# .env.production   (prod overrides)
# .env.test         (test overrides)

Reading Variables:

import os
from dotenv import load_dotenv

load_dotenv()

# Method 1: os.getenv() with default
DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite:///default.db')

# Method 2: os.environ.get() (same as getenv)
SECRET_KEY = os.environ.get('SECRET_KEY')

# Method 3: os.environ[] (raises KeyError if missing)
try:
    API_KEY = os.environ['API_KEY']
except KeyError:
    raise Exception("API_KEY not found in environment")

# Method 4: dotenv_values() - returns dict without modifying os.environ
from dotenv import dotenv_values

config = dotenv_values('.env')  # dict of env vars, doesn't modify os.environ
DATABASE_URL = config.get('DATABASE_URL')

Pydantic Settings Integration:

# config.py
from pydantic_settings import BaseSettings
from dotenv import load_dotenv

# Load .env before defining settings
load_dotenv()

class Settings(BaseSettings):
    database_url: str
    secret_key: str
    google_client_id: str
    google_client_secret: str
    debug: bool = False
    
    class Config:
        env_file = '.env'  # Pydantic can also load .env
        case_sensitive = False

settings = Settings()

# main.py
from config import settings

print(settings.database_url)

Docker and Production Considerations:

# Don't rely on .env in production containers
# Use actual environment variables instead

import os
from dotenv import load_dotenv

# Only load .env if not in production
if os.getenv('ENVIRONMENT') != 'production':
    load_dotenv()

# Or check if .env exists
from pathlib import Path

env_file = Path('.env')
if env_file.exists():
    load_dotenv(env_file)

Security Best Practices:

# 1. Never commit .env to version control
# Add to .gitignore:
# .env
# .env.*
# !.env.example

# 2. Use .env.example for documentation
# .env.example (commit this)
DATABASE_URL=postgresql://user:password@localhost/dbname
SECRET_KEY=generate-a-secret-key
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret

# 3. Validate required variables
from dotenv import load_dotenv
import os

load_dotenv()

required_vars = [
    'DATABASE_URL',
    'SECRET_KEY',
    'GOOGLE_CLIENT_ID',
    'GOOGLE_CLIENT_SECRET'
]

missing_vars = [var for var in required_vars if not os.getenv(var)]

if missing_vars:
    raise Exception(f"Missing required environment variables: {', '.join(missing_vars)}")

Common Pitfalls:

# ❌ WRONG: Loading .env after reading variables
import os
DATABASE_URL = os.getenv('DATABASE_URL')  # Returns None!

from dotenv import load_dotenv
load_dotenv()  # Too late

# ✅ CORRECT: Load .env first
from dotenv import load_dotenv
load_dotenv()

import os
DATABASE_URL = os.getenv('DATABASE_URL')  # Returns value from .env

# ❌ WRONG: Loading .env in module that's imported early
# config.py
import os
DATABASE_URL = os.getenv('DATABASE_URL')  # .env not loaded yet!

# main.py
from dotenv import load_dotenv
from config import DATABASE_URL  # config.py already executed!
load_dotenv()

# ✅ CORRECT: Load in main, read in config
# main.py
from dotenv import load_dotenv
load_dotenv()

from config import DATABASE_URL  # Now .env is loaded

Installation:

pip install python-dotenv

# Or with poetry
poetry add python-dotenv

# Or with pipenv
pipenv install python-dotenv

Version Note: python-dotenv 0.19.0+ supports Python 3.7+, load_dotenv() pattern consistent since 0.10.0

99% confidence
A

Starlette's Config class provides type-safe environment variable loading with automatic type casting, .env file integration, and default value handling. It's more robust than os.getenv() for production applications because it validates types and provides clear error messages for missing required variables.

Basic Comparison:

import os
from starlette.config import Config

# Method 1: os.getenv() - Returns strings or None
DATABASE_URL = os.getenv('DATABASE_URL')  # str or None
DEBUG = os.getenv('DEBUG')  # str '"True"' not bool True!
PORT = os.getenv('PORT')  # str '8000' not int 8000!

# Manual type conversion needed
DEBUG = os.getenv('DEBUG', 'False').lower() == 'true'
PORT = int(os.getenv('PORT', 8000))

# Method 2: Starlette Config - Type-safe with casting
config = Config('.env')  # Optionally specify .env file

DATABASE_URL = config('DATABASE_URL')  # str, raises if missing
DEBUG = config('DEBUG', cast=bool, default=False)  # Automatically converts to bool
PORT = config('PORT', cast=int, default=8000)  # Automatically converts to int

Starlette Config Features:

from starlette.config import Config
from starlette.datastructures import Secret, CommaSeparatedStrings

# Initialize Config (searches for .env in current directory)
config = Config()

# Or specify .env file path
config = Config('.env.production')

# 1. Required variables (raises ValueError if missing)
DATABASE_URL = config('DATABASE_URL')  # Must exist

# 2. Optional variables with defaults
DEBUG = config('DEBUG', cast=bool, default=False)
PORT = config('PORT', cast=int, default=8000)

# 3. Type casting
WORKERS = config('WORKERS', cast=int, default=4)
TIMEOUT = config('TIMEOUT', cast=float, default=30.5)
ENABLED = config('FEATURE_ENABLED', cast=bool, default=True)

# 4. Secret values (repr shows '**********')
SECRET_KEY = config('SECRET_KEY', cast=Secret)
print(SECRET_KEY)  # Output: '**********' (hides value)
print(str(SECRET_KEY))  # Actual value

# 5. Comma-separated values
ALLOWED_HOSTS = config(
    'ALLOWED_HOSTS',
    cast=CommaSeparatedStrings,
    default='localhost,127.0.0.1'
)
# ALLOWED_HOSTS=['localhost', '127.0.0.1']

# 6. Custom casting functions
def parse_log_level(value: str) -> int:
    import logging
    return getattr(logging, value.upper())

LOG_LEVEL = config('LOG_LEVEL', cast=parse_log_level, default='INFO')

Complete FastAPI Application Example:

# config.py
from starlette.config import Config
from starlette.datastructures import Secret, CommaSeparatedStrings

config = Config('.env')

# Application settings
APP_NAME = config('APP_NAME', default='My API')
DEBUG = config('DEBUG', cast=bool, default=False)
ENVIRONMENT = config('ENVIRONMENT', default='development')

# Server settings
HOST = config('HOST', default='0.0.0.0')
PORT = config('PORT', cast=int, default=8000)
WORKERS = config('WORKERS', cast=int, default=4)

# Database
DATABASE_URL = config('DATABASE_URL')
DB_POOL_SIZE = config('DB_POOL_SIZE', cast=int, default=10)
DB_MAX_OVERFLOW = config('DB_MAX_OVERFLOW', cast=int, default=20)

# Security
SECRET_KEY = config('SECRET_KEY', cast=Secret)
SESSION_SECRET = config('SESSION_SECRET', cast=Secret)

# OAuth
GOOGLE_CLIENT_ID = config('GOOGLE_CLIENT_ID')
GOOGLE_CLIENT_SECRET = config('GOOGLE_CLIENT_SECRET', cast=Secret)

# CORS
ALLOWED_ORIGINS = config(
    'ALLOWED_ORIGINS',
    cast=CommaSeparatedStrings,
    default='http://localhost:3000'
)

# Features
ENABLE_DOCS = config('ENABLE_DOCS', cast=bool, default=True)
ENABLE_METRICS = config('ENABLE_METRICS', cast=bool, default=False)

# main.py
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from authlib.integrations.starlette_client import OAuth
from config import (
    APP_NAME,
    DEBUG,
    SECRET_KEY,
    SESSION_SECRET,
    GOOGLE_CLIENT_ID,
    GOOGLE_CLIENT_SECRET,
    ALLOWED_ORIGINS,
    ENABLE_DOCS
)

app = FastAPI(
    title=APP_NAME,
    debug=DEBUG,
    docs_url='/docs' if ENABLE_DOCS else None
)

app.add_middleware(
    SessionMiddleware,
    secret_key=str(SESSION_SECRET)  # Convert Secret to str
)

oauth = OAuth()
oauth.register(
    name='google',
    client_id=GOOGLE_CLIENT_ID,
    client_secret=str(GOOGLE_CLIENT_SECRET),  # Convert Secret to str
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid email profile'}
)

.env File:

# .env
APP_NAME=AgentsKB API
DEBUG=True
ENVIRONMENT=development

HOST=0.0.0.0
PORT=8000
WORKERS=4

DATABASE_URL=postgresql://user:pass@localhost/dbname
DB_POOL_SIZE=10

SECRET_KEY=your-secret-key-32-chars-min
SESSION_SECRET=another-secret-key-here

GOOGLE_CLIENT_ID=123456.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-secret

# Comma-separated (no quotes needed)
ALLOWED_ORIGINS=http://localhost:3000,https://app.example.com

ENABLE_DOCS=true
ENABLE_METRICS=false

Type Casting Behavior:

from starlette.config import Config

config = Config()

# Boolean casting
# True: 'true', 'True', 'TRUE', '1', 'yes', 'on'
# False: 'false', 'False', 'FALSE', '0', 'no', 'off', '' (empty string)
DEBUG = config('DEBUG', cast=bool, default=False)

# Integer casting
PORT = config('PORT', cast=int, default=8000)
# Raises ValueError if value is not valid integer

# Float casting
TIMEOUT = config('TIMEOUT', cast=float, default=30.0)

# List casting
from starlette.datastructures import CommaSeparatedStrings

# "a,b,c" → ['a', 'b', 'c']
# "a, b, c" → ['a', 'b', 'c'] (strips whitespace)
VALUES = config('VALUES', cast=CommaSeparatedStrings)

Error Handling:

from starlette.config import Config

config = Config('.env')

try:
    # Raises ValueError if DATABASE_URL not in environment or .env
    DATABASE_URL = config('DATABASE_URL')
except ValueError as e:
    print(f"Configuration error: {e}")
    # Error message: "Config 'DATABASE_URL' is missing, and has no default."
    exit(1)

try:
    # Raises ValueError if PORT is not a valid integer
    PORT = config('PORT', cast=int)
except ValueError as e:
    print(f"Invalid PORT value: {e}")
    exit(1)

Comparison Summary:

Feature os.getenv() Starlette Config
Type safety ❌ Always returns str ✅ Automatic type casting
Required vars ❌ Manual validation ✅ Raises if missing
Default values ✅ Second parameter default= parameter
.env file ❌ Needs python-dotenv ✅ Built-in support
Secret hiding ❌ Shows in logs ✅ Secret type hides value
Lists/arrays ❌ Manual parsing ✅ CommaSeparatedStrings
Error messages ❌ Silent None ✅ Clear ValueError
IDE support ❌ Limited ✅ Better type hints

When to Use Each:

Use os.getenv():

  • Simple scripts or utilities
  • One-off environment checks
  • Already using python-dotenv extensively
  • Minimal dependencies preferred

Use Starlette Config:

  • FastAPI/Starlette applications
  • Production applications requiring type safety
  • Complex configuration with many variables
  • Need built-in .env file support
  • Want secret value protection

Pydantic Settings Alternative:

# Modern alternative: Pydantic Settings
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    app_name: str = 'My API'
    debug: bool = False
    database_url: str
    port: int = 8000
    google_client_id: str
    google_client_secret: str
    
    class Config:
        env_file = '.env'
        case_sensitive = False

settings = Settings()  # Automatically loads from .env and validates types

Version Note: Starlette Config available since Starlette 0.10.0+, Secret and CommaSeparatedStrings since 0.12.0+

99% confidence