Horizon

Test Supabase Auth redirects and OAuth locally

Test Supabase Auth sign-in and OAuth providers on localhost with a Horizon tunnel, and set the Site URL and Redirect URLs once.

Sign in with Supabase Auth on your laptop, with a redirect URL that stays the same across restarts.

Supabase only redirects to URLs on its Redirect URLs list. A stable Horizon URL means you set that list once.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A reserved subdomain for -s. Reserve one on the Subdomains page.
  • A Supabase project, and a Next.js app that uses @supabase/ssr
  • An OAuth provider enabled in your Supabase project. This page uses GitHub.

Start a tunnel

Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so the URLs you save in Supabase would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.

hrzn tunnel http://localhost:3000 -s my-app
Output
HORIZON: Tunnel connected
  URL          https://my-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-app

Your public URL is https://my-app.hrzn.run. Keep this terminal open.

The first time you open the URL in a browser, Horizon shows a Before you continue page. Select Continue to site. You see it once every 7 days per IP address, so the redirect back from Supabase won't surprise you later.

Add the redirect URL in Supabase

  1. Open the URL Configuration page of your project.
  2. Add https://my-app.hrzn.run/auth/callback to Redirect URLs.
  3. Save your changes.

Site URL is the default redirect URL when your code passes no redirectTo. Leave it alone unless you rely on that default. Your code in the next step passes redirectTo, so you don't need to change it.

Set the app environment variables

Install the packages.

npm install @supabase/supabase-js @supabase/ssr

Supabase needs no variable for the public URL. The app takes it from the browser and from X-Forwarded-Host, which the later code uses.

.env.local
NEXT_PUBLIC_SUPABASE_URL=your_project_url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_publishable_key

Create the server client, as in Supabase's server-side client guide.

lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            cookieStore.set(name, value),
          );
        },
      },
    },
  );
}

Create the browser client.

lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";

export const supabase = createBrowserClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);

Create the callback route. Supabase's example redirects to origin in development, and origin is built from the Host header, which Horizon sets to localhost:3000. This version always prefers X-Forwarded-Host, so the redirect lands on the public URL.

app/auth/callback/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");
  let next = searchParams.get("next") ?? "/";
  if (!next.startsWith("/")) {
    next = "/";
  }

  if (code) {
    const supabase = await createClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (!error) {
      const forwardedHost = request.headers.get("x-forwarded-host");
      const baseUrl = forwardedHost ? `https://${forwardedHost}` : origin;
      return NextResponse.redirect(`${baseUrl}${next}`);
    }
  }

  return NextResponse.redirect(`${origin}/auth/auth-code-error`);
}

Start sign-in from a client component. window.location.origin is the public URL when you open the app through it.

app/sign-in-button.tsx
"use client";

import { supabase } from "@/lib/supabase/client";

export function SignInButton() {
  const signIn = () =>
    supabase.auth.signInWithOAuth({
      provider: "github",
      options: { redirectTo: `${window.location.origin}/auth/callback` },
    });

  return <button onClick={signIn}>Sign in with GitHub</button>;
}

Render SignInButton on a page. Start the app on port 3000, and restart it after you edit .env.local.

npm run dev

Sign in through the public URL

Open https://my-app.hrzn.run and select Sign in with GitHub. Start from the public URL so window.location.origin is the Horizon address.

Check it works

The browser goes to the provider, then to Supabase, then to https://my-app.hrzn.run/auth/callback. Your app exchanges the code for a session and redirects to /.

Your Horizon terminal prints a GET line for /auth/callback. A 3xx status means your app redirected you onward. If the line shows a 4xx or 5xx, see Troubleshooting.

Troubleshooting

The sign-in lands on the Site URL, not the callback

Supabase rejects a redirectTo that isn't on the Redirect URLs list. Check that https://my-app.hrzn.run/auth/callback is on the list and matches redirectTo exactly. Supabase supports glob patterns, where * matches any run of characters except . and /, and ** matches any run of characters.

The callback goes to localhost

The route builds its redirect from origin, which comes from the Host header. Horizon sets Host to localhost:3000 and sends the public host in X-Forwarded-Host. Use the route above, which reads X-Forwarded-Host, or build the redirect from a base URL you set yourself.

The provider says the redirect URI doesn't match

The provider's callback setting needs the Supabase URL, https://<project-ref>.supabase.co/auth/v1/callback. It doesn't need the Horizon URL.

The tunnel URL changed

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.

Nothing reaches your app

  • Check that the Horizon terminal is still running. If its last line is Connection lost. Reconnecting…, wait for Reconnected.
  • Check that your app listens on port 3000.

Next steps

On this page