How to set up Supabase Auth on Webflow Cloud at the edge

Supabase Auth gives your Webflow Cloud app server-validated sessions and owned auth tables. Learn to wire up email and password sign-in at the edge.

How to set up Supabase Auth on Webflow Cloud at the edge

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

You can run a full email and password auth system on a Webflow Cloud app, validated at the edge on every request, without ever shipping a session token to the browser.

Supabase Auth comes up often in Webflow developer threads, usually once a project needs server-validated sessions, custom JWT claims, or auth tables the team controls directly.

With Webflow Cloud running your Next.js app on Cloudflare Workers, Supabase gives you a PostgreSQL-backed auth system that your edge functions can validate on every request, without shipping session logic to the browser.

In this guide, we explore wiring up email/password auth in a Webflow Cloud app: installing @supabase/ssr, creating the browser and server clients, adding the session proxy, building the Route Handlers that power sign-up and sign-in, and connecting Webflow forms on the front end.

What do you need to set up Supabase Auth in a Webflow Cloud App?

You need a Webflow Cloud project, a Supabase project, and Node.js 22 or higher. Supabase Auth runs on the free tier, so no paid plan is required to follow this guide.

Here’s the full list:

  • A Webflow site with Webflow Cloud enabled and a Next.js app deployed or running locally
  • A Supabase project (create one at supabase.com if you haven't already)
  • Node.js 22 or higher, required by the Webflow Cloud runtime
  • @supabase/supabase-js and @supabase/ssr, the two packages that handle Supabase operations and cookie-based session management

@supabase/supabase-js is isomorphic: it uses fetch internally. It doesn't depend on Node.js-specific APIs, so it runs cleanly on Cloudflare Workers. The @supabase/ssr package adds the cookie layer needed to manage sessions across server and client without leaking state.

9 steps to add Supabase Auth to your Webflow Cloud app

@supabase/supabase-js handles Supabase operations, and @supabase/ssr handles cookie-based session management across the edge. The session proxy (proxy.ts) is what keeps the server and browser in sync. It validates and refreshes the auth token on every incoming request before any Route Handler or Server Component runs.

Follow these steps to hook things up.

1. Create your Supabase project and copy the credentials

Head to the Supabase dashboard and create a new project. Give it a name, set a database password, and choose a region close to your users.

Once the project finishes provisioning, open Settings > API Keys in the left sidebar. Supabase now uses a new publishable key format, though the legacy anon and service_role keys still work during the transition. Under Publishable key, copy the value starting with sb_publishable_....

You'll also need the Project URL displayed at the top of the same page.

2. Install the packages and configure environment variables

In your Webflow Cloud Next.js project, install both packages:

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

Then create a .env.local file at the project root with your Supabase credentials:

# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key_here
NEXT_PUBLIC_SITE_URL=https://yoursite.webflow.io/app

NEXT_PUBLIC_SITE_URL is the base URL for your Webflow Cloud mount path and is used in the email confirmation redirect. Add all three to your Webflow Cloud environment as well: open your Cloud project in the Webflow dashboard, navigate to Environments, and add them under Environment Variables so they're available in production deployments.

3. Create the browser and server clients

Supabase needs two client types:

  • One for Client Components (browser)
  • One for Route Handlers and Server Components (server)

Create a lib/supabase/ folder at the project root with two files.

The browser client uses createBrowserClient, which internally follows a singleton pattern.

You can call it as many times as needed without creating duplicate instances:

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

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  )
}

The server client reads and writes cookies through Next.js's cookies() API. The try/catch on setAll is intentional: Server Components can't write cookies directly, so errors there are expected and safe to ignore:

// 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) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Called from a Server Component.
            // The session proxy handles cookie writes on every request.
          }
        },
      },
    }
  )
}

With both clients in place, your browser code and server code can each reach Supabase with the cookie handling that fits their context. The piece that keeps the two in sync is the session proxy, which runs before any of this code and refreshes the auth token on every request.

4. Add the session proxy

Next.js 16 renamed the middleware.ts convention to proxy.ts, and Webflow Cloud apps follow that convention. The file does the same job, running request-level logic before any Route Handler or Server Component. The Supabase session proxy validates the JWT signature and refreshes the auth token on every request.

You should create it at the project root:

// proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function proxy(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
          setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          )
          supabaseResponse = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  // getClaims() validates the JWT signature against Supabase's published
  // public keys on every request. Do not substitute getSession() here.
  // It trusts the cookie without revalidating the JWT.
  await supabase.auth.getClaims()

  return supabaseResponse
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

The comment in the code is worth taking seriously. <a href="https://supabase.com/docs/guides/auth/server-side/creating-a-client" target="_blank" rel="noopener noreferrer">getClaims()</a>validates the JWT signature against Supabase's published public keys every time. getSession() does not. It trusts the session data in the cookie as-is, which makes it unsuitable for server-side auth checks.

5. Build the auth Route Handlers

Route Handlers in Webflow Cloud run on the Node.js runtime provided by the OpenNext adapter, so you do not need to add an edge runtime export. In Route Handlers, pass cookies directly through the request and response objects, not throughnext/headers.

Each auth action gets its own Route Handler. Start with sign-up, sign-in, and sign-out:

Sign-up (app/api/auth/signup/route.ts):

export const runtime = 'edge'

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  const { email, password } = await request.json()
  const response = NextResponse.json({
    message: 'Check your email to confirm your account.'
  })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() { return request.cookies.getAll() },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  const { error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/confirm`,
    },
  })

  if (error) return NextResponse.json({ error: error.message }, { status: 400 })

  return response
}

Sign-in (app/api/auth/login/route.ts):

export const runtime = 'edge'

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  const { email, password } = await request.json()
  const response = NextResponse.json({ success: true })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() { return request.cookies.getAll() },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  const { error } = await supabase.auth.signInWithPassword({ email, password })

  if (error) return NextResponse.json({ error: error.message }, { status: 401 })

  return response
}

Sign-out (app/api/auth/logout/route.ts):

export const runtime = 'edge'

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  const response = NextResponse.json({ success: true })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() { return request.cookies.getAll() },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  await supabase.auth.signOut()
  return response
}

These three handlers cover the core session lifecycle of creating an account, signing in, and signing out. Sign-up returns a message telling the user to confirm their email, which is the route you'll add next.

6. Add the email confirmation route

Supabase sends a confirmation link to new users after sign-up. That link carries token_hash and type parameters and needs a GET Route Handler to verify them and activate the session. Without this route, email confirmation silently fails.

Create app/auth/confirm/route.ts:

// app/auth/confirm/route.ts
export const runtime = 'edge'

import { createServerClient } from '@supabase/ssr'
import { type EmailOtpType } from '@supabase/supabase-js'
import { NextResponse, type NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const token_hash = searchParams.get('token_hash')
  const type = searchParams.get('type') as EmailOtpType | null
  const next = searchParams.get('next') ?? '/'

  if (!token_hash || !type) {
    return NextResponse.redirect(new URL('/auth/auth-code-error', request.url))
  }

  const response = NextResponse.redirect(new URL(next, request.url))

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() { return request.cookies.getAll() },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  const { error } = await supabase.auth.verifyOtp({ token_hash, type })

  if (error) {
    return NextResponse.redirect(new URL('/auth/auth-code-error', request.url))
  }

  return response
}

After verifyOtp succeeds, Supabase writes a session cookie, and the redirect sends the user to the next path (defaulting to /). Make sure NEXT_PUBLIC_SITE_URL in your environment variables matches the mount path where this route lives. A mismatch is the most common reason confirmation links return 404.

7. Protect your member routes withgetClaims()

Any Route Handler that serves member-only content should verify the session before returning data. Call getClaims() at the top of the handler. If it returns null or an error, the user isn't authenticated.

Here’s the snippet:

// app/api/member-content/route.ts
export const runtime = 'edge'

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  const response = NextResponse.json({ content: null })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() { return request.cookies.getAll() },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  const { data: { claims }, error } = await supabase.auth.getClaims()

  if (error || !claims) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // claims.sub is the authenticated user's ID
  return NextResponse.json({
    userId: claims.sub,
    content: 'Member-only content returned here'
  })
}

To protect entire page routes rather than individual API endpoints, add redirect logic inside proxy.ts after the getClaims() call. Check request.nextUrl.pathname and if the path is protected and claims is null, return NextResponse.redirect() to your login page.

8. Wire up the forms on your Webflow pages

From a Webflow page, fetch() calls in a custom code embed or a Client Component trigger the auth endpoints. The session cookie is httpOnly, so the browser stores it automatically and sends it with subsequent requests, but JavaScript can't read it. That's the security advantage over client-side auth patterns.

Add a Code Embed to your Webflow login page with a form that calls the login Route Handler:

<!-- Code Embed on your Webflow login page -->
<form id="login-form">
  <input type="email" name="email" placeholder="Email" required />
  <input type="password" name="password" placeholder="Password" required />
  <button type="submit">Sign in</button>
  <p id="error-msg" style="color:red; display:none;"></p>
</form>

<script>
  document.getElementById('login-form').addEventListener('submit', async (e) => {
    e.preventDefault()
    const errorMsg = document.getElementById('error-msg')
    errorMsg.style.display = 'none'

    const formData = new FormData(e.target)
    const response = await fetch('/app/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        email: formData.get('email'),
        password: formData.get('password'),
      }),
    })

    if (response.ok) {
      window.location.href = '/members'
    } else {
      const { error } = await response.json()
      errorMsg.textContent = error
      errorMsg.style.display = 'block'
    }
  })
</script>

Replace /app/api/auth/login with your actual mount path if it differs. The sign-up form follows the same pattern. Swap the endpoint to/app/api/auth/signup and adjust the success redirect to prompt the user to check their email rather than navigate immediately.

9. Protect your Supabase tables with Row-Level Security

If you store member data in Supabase tables (subscription status, saved content, user profiles), enable Row Level Security to prevent one user from accessing another's rows.

In the Supabase dashboard, open Table Editor, select your table, and enable Row Level Security in the table settings.

Then create policies in the SQL editor. For a profiles table where users should only access their own row:

-- Supabase SQL Editor
CREATE POLICY "Users can view their own profile"
ON profiles
FOR SELECT
USING (auth.uid() = user_id);

CREATE POLICY "Users can update their own profile"
ON profiles
FOR UPDATE
USING (auth.uid() = user_id);

auth.uid() returns the authenticated user's ID from the validated JWT. Supabase evaluates it automatically on every query. Without an RLS policy explicitly allowing the operation, queries return no rows. The table is locked down by default once RLS is enabled.

What causes Supabase Auth to fail on your Webflow Cloud app?

The auth usually wires up cleanly, but one of these three issues tends to appear first in testing.

Sessions don't persist after sign-in

This almost always means the setAll handler isn't writing cookies to the response object. Check that your Route Handlers create the response object before the Supabase client does, and pass cookies to that exact object.

If you create a new NextResponse() after the setAll call, the cookies are written to a discarded object and lost. Build the response once, before the client, and write to it throughout.

getClaims()returns null immediately after sign-in

There's a short window on the first request after sign-in before the token propagates through the proxy. In protected Route Handlers, treat null claims as unauthenticated and return 401. Don't fall back to getSession(). On the client side, retry the request or redirect to the login page. Don't suppress the 401 to mask the symptom.

Email confirmation link returns 404

The confirmation link lands at [mount-path]/auth/confirm?token_hash=...&type=.... If it 404s, check two things: that app/auth/confirm/route.ts exists at the right path in your project, and that NEXT_PUBLIC_SITE_URL matches your actual mount path exactly, including the subdirectory if you're mounted at /app rather than the root.

Build more with Supabase on Webflow Cloud

The auth layer in this guide is the foundation for any member-gated feature. Subscription checks, saved preferences, Realtime database subscriptions, and user-generated content all build on the same session cookie pattern and the same Route Handler structure.

Explore Webflow + Supabase for the full picture of what you can connect to a Webflow site. For deeper customization of your Webflow Cloud app at the edge, Webflow's developer documentation covers environment configuration, deployment options, and storage.

Frequently asked questions

Can I use social login providers like Google or GitHub?

Yes. Supabase supports Google, GitHub, Twitter, and dozens of other OAuth providers. Configure your chosen provider in the Supabase dashboard under Authentication > Providers, then call supabase.auth.signInWithOAuth() from a Client Component. The same session proxy and getClaims() pattern handles token refresh regardless of which provider you use.

Does Webflow Cloud require a paid plan?

Webflow Cloud is available across Webflow plans, including the free Starter plan, with higher limits on projects, storage, and requests on paid tiers. Supabase Auth is free up to 50,000 monthly active users.

How do I add user profile data, such as display names and avatars?

Create a profiles table in Supabase with a user_id column that references auth.users(id). Add a database trigger in the SQL editor to automatically insert a profile row when a new user confirms their email. Enable RLS on the profiles table with SELECT and UPDATE policies scoped to auth.uid() = user_id. Supabase's Next.js user management tutorial covers the full schema and trigger setup.

Can I gate specific Webflow CMS content based on auth status?

Yes, and two patterns work depending on what you're gating. For gating entire CMS pages, deliver the content via a protected Route Handler and render it in your Cloud app rather than on a static Webflow page. For gating elements within a Webflow page, call your protected endpoint with fetch() in a Code Embed after the user authenticates and inject the response into the DOM. The first approach is cleaner for content that's only useful to members; the second works when most of the page is public, with member-only sections.


Last Updated
September 4, 2026
Category

Related articles

How to wire Auth0 to a Webflow Cloud App for server-side session validation
How to wire Auth0 to a Webflow Cloud App for server-side session validation

How to wire Auth0 to a Webflow Cloud App for server-side session validation

How to wire Auth0 to a Webflow Cloud App for server-side session validation

Development
By
Colin Lateano
,
,
Read article
How to use the Webflow custom code API to push scripts to specific pages
How to use the Webflow custom code API to push scripts to specific pages

How to use the Webflow custom code API to push scripts to specific pages

How to use the Webflow custom code API to push scripts to specific pages

Development
By
Colin Lateano
,
,
Read article
How to set up Stripe Checkout in Webflow using payment links or the buy button
How to set up Stripe Checkout in Webflow using payment links or the buy button

How to set up Stripe Checkout in Webflow using payment links or the buy button

How to set up Stripe Checkout in Webflow using payment links or the buy button

Development
By
Colin Lateano
,
,
Read article
How to add Gemini AI to a Webflow site securely using Webflow Cloud
How to add Gemini AI to a Webflow site securely using Webflow Cloud

How to add Gemini AI to a Webflow site securely using Webflow Cloud

How to add Gemini AI to a Webflow site securely using Webflow Cloud

Development
By
Colin Lateano
,
,
Read article

verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.