Challenges in multi-tenant authentication

Published on
Challenges in multi-tenant authentication

I recently ran into an interesting authentication challenge with my Vercel deployments. The core issue wasn’t just about protecting the application - it was about managing GitHub OAuth authentication across multiple domains: my production domain, localhost for development, and those auto-generated Vercel deployment URLs.

The challenge stems from a GitHub OAuth2 limitation: each OAuth app can only have one callback URL. This meant I needed separate GitHub OAuth apps for:

  • Production domain
  • Local development (localhost)
  • Vercel-generated deployment URLs

Initially, I tried Cloudflare Access for protection, but discovered a limitation: Vercel automatically generates unique deployment URLs not just for feature branches (which I expected), but also for the main branch. While Vercel’s built-in authentication can protect feature branch deployments, it doesn’t cover these auto-generated production URLs.

My first attempt at a solution was NextAuth. The implementation focused on matching different GitHub OAuth credentials based on the domain:

const getGitHubCredentials = () => {
  const vercelUrl = process.env.VERCEL_URL
  const nextAuthUrl = process.env.NEXTAUTH_URL
  const fullUrl = vercelUrl || nextAuthUrl || ''
  
  let host = ''
  try {
    if (fullUrl.startsWith('http')) {
      const url = new URL(fullUrl)
      host = url.hostname
    } else {
      host = fullUrl.trim().toLowerCase()
    }

    // Domain-specific credentials
    if (host.includes("git-main-arun-s")) {
      return {
        clientId: process.env.AUTH_GITHUB_ID_GIT_MAIN,
        clientSecret: process.env.AUTH_GITHUB_SECRET_GIT_MAIN
      }
    }
    
    // More credential matching logic...
  } catch (error) {
    console.error('Error in getGitHubCredentials:', error)
    return {
      clientId: process.env.AUTH_GITHUB_ID,
      clientSecret: process.env.AUTH_GITHUB_SECRET
    }
  }
}

However, I ran into issues with host detection reliability since NextAuth initializes providers before having access to the request context. This made the domain-based credential selection inconsistent.

That’s when I moved to Clerk. While Clerk also has its limitations - it only supports one domain per application (production domain for production and localhost for development) - it provides a useful workaround through its <Protect /> component. This component can protect access to elements without requiring login, essentially acting as a gatekeeper.

A screenshot of Clerk’s <Protect /> component that prevents access to parts of a frontend application based on roles.

Here’s what my current setup looks like:

  1. Production domain works with the production Clerk application
  2. localhost works with the development Clerk application
  3. Vercel-generated deployment URLs are protected via Clerk’s <Protect /> module - not a perfect solution, but a workable one for now

Here’s how my final application looks on one of the Vercel deployment URLs, which cannot be disabled. “Sign in” doesn’t work because of Clerk’s lack of support for multiple domains per application.

A screenshot of a frontend application on a Vercel deployment URL, which cannot be disabled, gated by Clerk login.

Is it ideal? Not quite. I’d prefer either of these:

  1. GitHub OAuth supporting multiple callback URLs per app (eliminating the need for multiple apps)
  2. Clerk supporting multiple domains per application
  3. Vercel providing more control over deployment URL generation

But for now, this combination of Clerk’s protection mechanisms offers a reasonable middle ground. The experience has taught me that sometimes in authentication architecture, we need to work creatively within the constraints of multiple services’ limitations.