Skip to main content

Clerk authentication proxy for custom domains (ngrok, tunnels, etc.)

Project description

clerkway

Clerk authentication proxy for custom domains (ngrok, Cloudflare Tunnel, etc.)

Problem

When using Clerk with a custom domain proxy (like ngrok), OAuth flows break because:

  1. Cookie domains - Clerk sets cookies for frontend-api.clerk.dev, not your domain
  2. Redirect URLs - OAuth callbacks redirect to Clerk's domain, not your proxy
  3. Multiple cookies - Standard header handling loses cookies (only keeps one)
  4. JWKS auth - Clerk's Backend API requires Authorization + User-Agent headers

This library solves all of these issues.

Installation

pip install clerkway

Quick Start

1. Add the proxy router to your FastAPI app

import os
from fastapi import FastAPI
from clerkway import create_clerk_proxy_router, ClerkProxyConfig

app = FastAPI()

# Configure the proxy
config = ClerkProxyConfig(
    secret_key=os.environ["CLERK_SECRET_KEY"],
)

# Add the router - this creates /__clerk/* routes
app.include_router(create_clerk_proxy_router(config))

2. Configure your frontend

In your Clerk provider configuration, set the proxy URL:

// React example
<ClerkProvider
  publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}
  proxyUrl="/__clerk"
>

3. Set up JWT verification

import os
import jwt
from clerkway import get_jwks_client

# Use Clerk's Backend API for fast JWKS fetching
jwks_client = get_jwks_client(
    jwks_url="https://api.clerk.com/v1/jwks",
    secret_key=os.environ["CLERK_SECRET_KEY"],
)

def verify_token(token: str) -> dict:
    signing_key = jwks_client.get_signing_key_from_jwt(token)
    return jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],
        # Add issuer verification for your domain
        issuer=f"https://{os.environ['YOUR_DOMAIN']}/__clerk",
    )

Environment Variables

Variable Description
CLERK_SECRET_KEY Your Clerk secret key (from Dashboard)
CLERK_PUBLISHABLE_KEY Your Clerk publishable key (frontend)

Configuration Options

@dataclass
class ClerkProxyConfig:
    secret_key: str              # Required: Clerk secret key
    route_prefix: str = "/__clerk"  # URL prefix for proxy routes
    timeout: float = 30.0        # HTTP request timeout
    log_oauth_callbacks: bool = True  # Log OAuth flow for debugging

How It Works

Cookie Rewriting

Clerk sets cookies with domain=frontend-api.clerk.dev. The proxy rewrites these to your domain so the browser stores them correctly.

Location Header Rewriting

OAuth callbacks redirect to Clerk's domain. The proxy rewrites Location headers to keep users on your domain:

  • /v1/oauth_callback?.../__clerk/v1/oauth_callback?...
  • https://frontend-api.clerk.dev/...https://yourdomain.com/__clerk/...

Multi-Cookie Handling

HTTP allows multiple Set-Cookie headers. Standard Python dict handling loses all but one. This library uses multi_items() and headers.append() to preserve all cookies.

JWKS Authentication

Clerk's Backend API (api.clerk.com/v1/jwks) requires:

  • Authorization: Bearer <secret_key> header
  • User-Agent header (urllib omits this by default)

The ClerkJWKClient class handles both automatically.

Vite Configuration

If using Vite, add your ngrok domain to allowedHosts:

// vite.config.ts
export default defineConfig({
  server: {
    allowedHosts: [
      "localhost",
      "your-app.ngrok.dev",
    ],
  },
});

Troubleshooting

403 Forbidden on JWKS fetch

You're missing the Authorization or User-Agent header. Use get_jwks_client() which handles this automatically.

Cookies not being set

Check that:

  1. Your domain matches what's in the rewritten cookies
  2. You're using HTTPS (required for Secure cookies)
  3. The proxy is correctly rewriting domain= in Set-Cookie headers

OAuth redirect loop

The Location header isn't being rewritten. Check that:

  1. The proxy is handling the OAuth callback path
  2. follow_redirects=False is set on the HTTP client

Usage Example (Complete FastAPI App)

"""Example FastAPI application with Clerk authentication via ngrok proxy."""
import os
from dataclasses import dataclass
from typing import Annotated

import jwt
from fastapi import Depends, FastAPI, HTTPException, Request, status

from clerkway import (
    ClerkProxyConfig,
    create_clerk_proxy_router,
    get_jwks_client,
)

app = FastAPI(title="My App")

# --- Clerk Proxy Setup ---
clerk_config = ClerkProxyConfig(
    secret_key=os.environ["CLERK_SECRET_KEY"],
)
app.include_router(create_clerk_proxy_router(clerk_config))

# --- JWT Verification ---
CLERK_JWKS_URL = "https://api.clerk.com/v1/jwks"
CLERK_ISSUER = f"https://{os.environ['APP_DOMAIN']}/__clerk"


@dataclass
class ClerkUser:
    clerk_id: str
    email: str | None = None
    name: str | None = None


def get_jwks_client_instance():
    return get_jwks_client(
        jwks_url=CLERK_JWKS_URL,
        secret_key=os.environ["CLERK_SECRET_KEY"],
    )


async def get_current_user(request: Request) -> ClerkUser:
    """FastAPI dependency to get the authenticated user."""
    auth_header = request.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Not authenticated",
        )
  
    token = auth_header[7:]
  
    try:
        jwks_client = get_jwks_client_instance()
        signing_key = jwks_client.get_signing_key_from_jwt(token)
        payload = jwt.decode(
            token,
            signing_key.key,
            algorithms=["RS256"],
            issuer=CLERK_ISSUER,
        )
        return ClerkUser(
            clerk_id=payload.get("sub", ""),
            email=payload.get("email"),
            name=payload.get("name"),
        )
    except jwt.InvalidTokenError as e:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token",
        ) from e


CurrentUser = Annotated[ClerkUser, Depends(get_current_user)]


# --- Routes ---
@app.get("/api/me")
async def get_me(user: CurrentUser):
    return {"clerk_id": user.clerk_id, "email": user.email, "name": user.name}


@app.get("/api/public")
async def public_endpoint():
    return {"message": "This is public"}

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

clerkway-0.1.1.tar.gz (10.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

clerkway-0.1.1-py3-none-any.whl (9.8 kB view details)

Uploaded Python 3

File details

Details for the file clerkway-0.1.1.tar.gz.

File metadata

  • Download URL: clerkway-0.1.1.tar.gz
  • Upload date:
  • Size: 10.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.1

File hashes

Hashes for clerkway-0.1.1.tar.gz
Algorithm Hash digest
SHA256 533b178d16d899e93185b1706d7eee74ee8826e2f6bd0e4de5b38f0d5bd7d8ed
MD5 551bfcdd46ceeb065633f9690d9bb7bd
BLAKE2b-256 fc4f5beabdfc532c8d2463517f359484dc4ebdbf60203d52a0bb5236eb9557c3

See more details on using hashes here.

File details

Details for the file clerkway-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: clerkway-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 9.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.1

File hashes

Hashes for clerkway-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 5bc0fbae9b22ca0bb9aa30a2515b00caf13d1493d43827d6fded7ccb85377d01
MD5 621e7de5a9d3b62e0f8f4f2d15403883
BLAKE2b-256 1e81663da9c9a9b207eb078d73a6f35defc861e52358409356302922d115f487

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page