Supabase + AWS Lambda + Vercel: A Practical Full-Stack Sample Project

Last updated: 2026-04-22

This guide walks through a concrete sample project that uses:

The sample app is a small authenticated notes app:

This is a good architecture when:

It is not the simplest possible stack. The whole point of this guide is to show how to make the cross-vendor auth flow explicit and safe.


Table of Contents

  1. What We Are Building
  2. The Main Architectural Rule
  3. High-Level Architecture
  4. Project Layout
  5. How to Think About User Data
  6. JWT Handshake Across Vercel, Lambda, and Supabase
  7. Supabase Schema and RLS
  8. Frontend Code on Vercel
  9. AWS Lambda API Code
  10. AWS API Gateway and Deployment Shape
  11. Environment Variables
  12. Local Development Workflow
  13. How User Data Should Flow
  14. Common Mistakes in This Stack
  15. When to Choose a Different Infra Mix
  16. Recommended Variants
  17. Official References

What We Are Building

The sample project has these user-facing features:

The sample project has these backend rules:


The Main Architectural Rule

Use Supabase Auth for identity, and use Postgres RLS for authorization, even when your API runs on AWS Lambda.

That means:

If you keep this rule, the stack stays coherent.

If you break this rule and use a privileged key for everyday user traffic, your app will still appear to work, but your authorization model becomes much easier to get wrong.


High-Level Architecture

flowchart TD
    A[Vercel Frontend React or Vite] -->|sign in| B[Supabase Auth]
    B -->|access token JWT| A
    A -->|Authorization Bearer JWT| C[API Gateway]
    C --> D[AWS Lambda API]
    D -->|verify JWT via JWKS| E[Supabase Auth JWKS]
    D -->|publishable key plus user JWT| F[Supabase Data API]
    F --> G[(Postgres + RLS)]
    B --> G

Why this split can make sense

What this split costs you

If you do not actually need Lambda, there are simpler options later in this guide.


Project Layout

A practical monorepo layout:

mynote-stack/
  apps/
    api/
      src/
        auth.ts
        handler.ts
      package.json
      tsconfig.json
      template.yaml
    web/
      src/
        lib/
          api.ts
          supabase.ts
        App.tsx
      package.json
      vite.config.ts
  supabase/
    migrations/
      202604220001_initial_schema.sql

The responsibilities are:


How to Think About User Data

This is the first place teams get confused.

Do not treat Supabase Auth as the place to store all app user data.

A much better split is:

erDiagram
    AUTH_USERS ||--|| PROFILES : "1 to 1"
    PROFILES ||--o{ NOTES : "1 to many"

    AUTH_USERS {
      uuid id
      string email
      json raw_user_meta_data
    }

    PROFILES {
      uuid id
      text display_name
      text avatar_url
      timestamptz created_at
      timestamptz updated_at
    }

    NOTES {
      uuid id
      uuid user_id
      text title
      text body
      timestamptz created_at
      timestamptz updated_at
    }

Practical rules for user data

What should live in the JWT

Usually:

What should not usually live in the JWT

JWTs are for short-lived identity and auth context, not as your main user data store.


JWT Handshake Across Vercel, Lambda, and Supabase

This is the most important part of the whole guide.

Sign-up and session creation flow

sequenceDiagram
    participant U as User Browser
    participant V as Vercel Frontend
    participant SA as Supabase Auth
    participant DB as Supabase Postgres

    U->>V: Sign up
    V->>SA: supabase.auth.signUp
    SA-->>V: session with access token JWT
    SA->>DB: create auth.users row
    DB-->>SA: trigger creates public.profiles row

On later sign-ins for an existing user, Supabase returns a new session JWT but does not create a new auth.users row.

Authenticated API call flow

sequenceDiagram
    participant U as User Browser
    participant V as Vercel Frontend
    participant L as AWS Lambda
    participant J as Supabase JWKS
    participant S as Supabase Data API
    participant P as Postgres with RLS

    U->>V: Open authenticated dashboard
    V->>V: Read current Supabase session
    V->>L: GET /me with Authorization Bearer access_token
    L->>J: Fetch or use cached JWKS and verify token
    J-->>L: Public key material
    L->>S: Query profile and notes using same user JWT
    S->>P: Apply RLS using auth.uid()
    P-->>S: Only rows user may read
    S-->>L: Filtered result
    L-->>V: JSON response

The two key security points

  1. Lambda verifies the JWT before trusting claims like sub.
  2. Lambda still queries Supabase as the user, not as an all-powerful admin.

That second point is what keeps RLS meaningful.

For modern Supabase projects using asymmetric signing keys, verify the JWT against the project’s JWKS endpoint:

https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json

If your project still uses the legacy shared-secret JWT setup, Supabase docs recommend verification through the Auth server rather than assuming JWKS-based local verification will work.

Optional API Gateway JWT authorizer

AWS API Gateway supports JWT authorizers. In theory, you can push some JWT validation earlier to the gateway.

In practice, many teams using Supabase Auth still start with verification inside Lambda because:

A practical progression is:

  1. verify in Lambda first
  2. add an authorizer later if you want gateway-level rejection

Supabase Schema and RLS

Create a migration such as supabase/migrations/202604220001_initial_schema.sql:

create extension if not exists pgcrypto;

create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
  new.updated_at = now();
  return new;
end;
$$;

create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  display_name text,
  avatar_url text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create table public.notes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references public.profiles(id) on delete cascade,
  title text not null check (char_length(title) between 1 and 200),
  body text not null default '',
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create trigger set_profiles_updated_at
before update on public.profiles
for each row
execute function public.set_updated_at();

create trigger set_notes_updated_at
before update on public.notes
for each row
execute function public.set_updated_at();

create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
  insert into public.profiles (id, display_name)
  values (
    new.id,
    coalesce(new.raw_user_meta_data ->> 'display_name', split_part(new.email, '@', 1))
  );
  return new;
end;
$$;

drop trigger if exists on_auth_user_created on auth.users;

create trigger on_auth_user_created
after insert on auth.users
for each row
execute function public.handle_new_user();

alter table public.profiles enable row level security;
alter table public.notes enable row level security;

create policy "users can view own profile"
on public.profiles
for select
using ((select auth.uid()) = id);

create policy "users can update own profile"
on public.profiles
for update
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);

create policy "users can read own notes"
on public.notes
for select
using ((select auth.uid()) = user_id);

create policy "users can insert own notes"
on public.notes
for insert
with check ((select auth.uid()) = user_id);

create policy "users can update own notes"
on public.notes
for update
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);

create policy "users can delete own notes"
on public.notes
for delete
using ((select auth.uid()) = user_id);

Why this schema works well

The most important application rule

When the browser calls Lambda to create a note, the browser should send:

It should not send:

Lambda derives the user from the verified JWT.


Frontend Code on Vercel

This sample uses a Vite React app deployed on Vercel.

Frontend environment variables

Create .env.local for local development:

VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key
VITE_API_BASE_URL=https://your-api.example.com

In Vercel, configure the same variables in the project settings for:

src/lib/supabase.ts

import { createClient } from "@supabase/supabase-js";

export const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
);

src/lib/api.ts

This file is the handoff point between the Supabase-authenticated browser session and the AWS API.

import { supabase } from "./supabase";

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;

export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
  const {
    data: { session },
  } = await supabase.auth.getSession();

  if (!session?.access_token) {
    throw new Error("No active Supabase session");
  }

  const headers = new Headers(init.headers);
  headers.set("Authorization", `Bearer ${session.access_token}`);

  if (init.body && !headers.has("Content-Type")) {
    headers.set("Content-Type", "application/json");
  }

  const response = await fetch(`${API_BASE_URL}${path}`, {
    ...init,
    headers,
  });

  if (!response.ok) {
    const message = await response.text();
    throw new Error(message || `Request failed with ${response.status}`);
  }

  return response.json() as Promise<T>;
}

src/App.tsx

This is intentionally small. The point is to show the auth flow clearly.

import { FormEvent, useEffect, useState } from "react";
import { apiFetch } from "./lib/api";
import { supabase } from "./lib/supabase";

type Note = {
  id: string;
  title: string;
  body: string;
  created_at: string;
};

type MeResponse = {
  user: {
    id: string;
    email: string | null;
    profile: {
      display_name: string | null;
      avatar_url: string | null;
    } | null;
  };
  notes: Note[];
};

export default function App() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [displayName, setDisplayName] = useState("");
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");
  const [me, setMe] = useState<MeResponse | null>(null);
  const [loading, setLoading] = useState(true);

  async function loadMe() {
    const data = await apiFetch<MeResponse>("/me");
    setMe(data);
  }

  useEffect(() => {
    supabase.auth.getSession().then(async ({ data }) => {
      if (data.session) {
        await loadMe();
      }
      setLoading(false);
    });

    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange(async (_event, session) => {
      if (session) {
        await loadMe();
      } else {
        setMe(null);
      }
    });

    return () => subscription.unsubscribe();
  }, []);

  async function signUp(event: FormEvent) {
    event.preventDefault();

    const { error } = await supabase.auth.signUp({
      email,
      password,
      options: {
        data: {
          display_name: displayName,
        },
      },
    });

    if (error) {
      alert(error.message);
      return;
    }

    alert("Sign-up succeeded. Confirm your email if confirmation is enabled.");
  }

  async function signIn(event: FormEvent) {
    event.preventDefault();

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

    if (error) {
      alert(error.message);
    }
  }

  async function signOut() {
    await supabase.auth.signOut();
    setMe(null);
  }

  async function createNote(event: FormEvent) {
    event.preventDefault();

    await apiFetch<Note>("/notes", {
      method: "POST",
      body: JSON.stringify({ title, body }),
    });

    setTitle("");
    setBody("");
    await loadMe();
  }

  if (loading) {
    return <p>Loading...</p>;
  }

  if (!me) {
    return (
      <main>
        <h1>MyNote Sample</h1>

        <form onSubmit={signUp}>
          <input
            value={displayName}
            onChange={(event) => setDisplayName(event.target.value)}
            placeholder="Display name"
          />
          <input
            value={email}
            onChange={(event) => setEmail(event.target.value)}
            placeholder="Email"
          />
          <input
            type="password"
            value={password}
            onChange={(event) => setPassword(event.target.value)}
            placeholder="Password"
          />
          <button type="submit">Sign up</button>
        </form>

        <form onSubmit={signIn}>
          <input
            value={email}
            onChange={(event) => setEmail(event.target.value)}
            placeholder="Email"
          />
          <input
            type="password"
            value={password}
            onChange={(event) => setPassword(event.target.value)}
            placeholder="Password"
          />
          <button type="submit">Sign in</button>
        </form>
      </main>
    );
  }

  return (
    <main>
      <h1>Welcome {me.user.profile?.display_name ?? me.user.email ?? me.user.id}</h1>
      <button onClick={signOut}>Sign out</button>

      <form onSubmit={createNote}>
        <input
          value={title}
          onChange={(event) => setTitle(event.target.value)}
          placeholder="Note title"
        />
        <textarea
          value={body}
          onChange={(event) => setBody(event.target.value)}
          placeholder="Note body"
        />
        <button type="submit">Create note</button>
      </form>

      <ul>
        {me.notes.map((note) => (
          <li key={note.id}>
            <strong>{note.title}</strong>
            <p>{note.body}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

Why the frontend code is deliberately thin

The frontend should:

The frontend should not be the only place where authorization logic lives.


AWS Lambda API Code

Install packages

npm install @supabase/supabase-js jose
npm install -D typescript esbuild @types/aws-lambda

src/auth.ts

This file is where Lambda verifies the Supabase JWT and creates a user-scoped Supabase client.

import { createClient } from "@supabase/supabase-js";
import { createRemoteJWKSet, JWTPayload, jwtVerify } from "jose";

const SUPABASE_URL = process.env.SUPABASE_URL!;
const SUPABASE_PUBLISHABLE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY!;
const SUPABASE_ISSUER = `${SUPABASE_URL}/auth/v1`;

const PROJECT_JWKS = createRemoteJWKSet(
  new URL(`${SUPABASE_ISSUER}/.well-known/jwks.json`)
);

export type AuthClaims = JWTPayload & {
  sub: string;
  email?: string;
  role?: string;
};

export function getBearerToken(headerValue?: string) {
  if (!headerValue?.startsWith("Bearer ")) {
    return null;
  }

  return headerValue.slice("Bearer ".length);
}

export async function verifySupabaseJwt(jwt: string): Promise<AuthClaims> {
  const { payload } = await jwtVerify(jwt, PROJECT_JWKS, {
    issuer: SUPABASE_ISSUER,
  });

  if (typeof payload.sub !== "string") {
    throw new Error("Invalid JWT subject");
  }

  return payload as AuthClaims;
}

export function createUserScopedSupabaseClient(accessToken: string) {
  return createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
    accessToken: async () => accessToken,
    auth: {
      persistSession: false,
      autoRefreshToken: false,
      detectSessionInUrl: false,
    },
  });
}

Why this is the right boundary

src/handler.ts

This file implements three routes:

import type {
  APIGatewayProxyEventV2,
  APIGatewayProxyStructuredResultV2,
} from "aws-lambda";
import {
  createUserScopedSupabaseClient,
  getBearerToken,
  verifySupabaseJwt,
} from "./auth";

function parseAllowlist() {
  return (process.env.CORS_ALLOWLIST ?? "")
    .split(",")
    .map((value) => value.trim())
    .filter(Boolean);
}

function corsHeaders(origin?: string) {
  const allowlist = parseAllowlist();
  const allowOrigin = origin && allowlist.includes(origin) ? origin : allowlist[0] ?? "";

  return {
    "access-control-allow-origin": allowOrigin,
    "access-control-allow-headers": "authorization,content-type",
    "access-control-allow-methods": "GET,POST,OPTIONS",
    vary: "Origin",
  };
}

function json(
  statusCode: number,
  origin: string | undefined,
  body: unknown
): APIGatewayProxyStructuredResultV2 {
  return {
    statusCode,
    headers: {
      ...corsHeaders(origin),
      "content-type": "application/json",
    },
    body: JSON.stringify(body),
  };
}

export async function handler(
  event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyStructuredResultV2> {
  const origin = event.headers.origin ?? event.headers.Origin;
  const method = event.requestContext.http.method;
  const path = event.rawPath;

  if (method === "OPTIONS") {
    return {
      statusCode: 204,
      headers: corsHeaders(origin),
    };
  }

  if (method === "GET" && path === "/health") {
    return json(200, origin, { ok: true });
  }

  const authHeader = event.headers.authorization ?? event.headers.Authorization;
  const accessToken = getBearerToken(authHeader);

  if (!accessToken) {
    return json(401, origin, { error: "Missing bearer token" });
  }

  let claims;

  try {
    claims = await verifySupabaseJwt(accessToken);
  } catch {
    return json(401, origin, { error: "Invalid or expired JWT" });
  }

  const supabase = createUserScopedSupabaseClient(accessToken);

  if (method === "GET" && path === "/me") {
    const [{ data: profile, error: profileError }, { data: notes, error: notesError }] =
      await Promise.all([
        supabase
          .from("profiles")
          .select("display_name, avatar_url")
          .eq("id", claims.sub)
          .single(),
        supabase
          .from("notes")
          .select("id, title, body, created_at")
          .order("created_at", { ascending: false }),
      ]);

    if (profileError) {
      return json(500, origin, { error: profileError.message });
    }

    if (notesError) {
      return json(500, origin, { error: notesError.message });
    }

    return json(200, origin, {
      user: {
        id: claims.sub,
        email: typeof claims.email === "string" ? claims.email : null,
        profile,
      },
      notes,
    });
  }

  if (method === "POST" && path === "/notes") {
    const payload = event.body ? JSON.parse(event.body) : {};

    if (typeof payload.title !== "string" || payload.title.trim() === "") {
      return json(400, origin, { error: "title is required" });
    }

    const body = typeof payload.body === "string" ? payload.body : "";

    const { data, error } = await supabase
      .from("notes")
      .insert({
        user_id: claims.sub,
        title: payload.title.trim(),
        body,
      })
      .select("id, title, body, created_at")
      .single();

    if (error) {
      return json(500, origin, { error: error.message });
    }

    return json(201, origin, data);
  }

  return json(404, origin, { error: "Not found" });
}

The most important line in the Lambda code

This line:

accessToken: async () => accessToken

means:

“Query Supabase using the current user’s JWT.”

That is the line that preserves user-context authorization instead of silently switching to admin-context access.

Optional authoritative user lookup

If you need to confirm user details directly with the Auth server instead of trusting the JWT claims alone, use auth.getUser(jwt) server-side.

That is useful when:

For most request-path authorization in modern projects, JWKS verification plus user-scoped Supabase queries is the better default.


AWS API Gateway and Deployment Shape

flowchart LR
    A[Vercel Frontend] --> B[API Gateway HTTP API]
    B --> C[Lambda Handler]
    C --> D[Supabase]
    C --> E[AWS services optional SQS SNS SES S3]

Minimal AWS SAM template

template.yaml:

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Resources:
  NotesHttpApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      CorsConfiguration:
        AllowOrigins:
          - http://localhost:5173
          - https://your-app.vercel.app
        AllowHeaders:
          - authorization
          - content-type
        AllowMethods:
          - GET
          - POST
          - OPTIONS

  NotesFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: nodejs22.x
      Handler: dist/handler.handler
      CodeUri: .
      MemorySize: 512
      Timeout: 10
      Environment:
        Variables:
          SUPABASE_URL: https://your-project-ref.supabase.co
          SUPABASE_PUBLISHABLE_KEY: your-publishable-key
          CORS_ALLOWLIST: http://localhost:5173,https://your-app.vercel.app
      Events:
        GetHealth:
          Type: HttpApi
          Properties:
            ApiId: !Ref NotesHttpApi
            Path: /health
            Method: GET
        GetMe:
          Type: HttpApi
          Properties:
            ApiId: !Ref NotesHttpApi
            Path: /me
            Method: GET
        PostNotes:
          Type: HttpApi
          Properties:
            ApiId: !Ref NotesHttpApi
            Path: /notes
            Method: POST

Practical note on CORS and Vercel previews

Vercel preview deployments create changing URLs.

That means you need to think about one of these:

Do not casually allow every possible origin just to get past CORS errors.


Environment Variables

Vercel frontend

Use these on Vercel:

VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key
VITE_API_BASE_URL=https://your-api.example.com

AWS Lambda

Use these in Lambda:

SUPABASE_URL=https://your-project-ref.supabase.co
SUPABASE_PUBLISHABLE_KEY=your-publishable-key
CORS_ALLOWLIST=http://localhost:5173,https://your-app.vercel.app

When to use SUPABASE_SERVICE_ROLE_KEY

Only use SUPABASE_SERVICE_ROLE_KEY in Lambda for:

Do not use it for routine user API requests.

Admin job flow

flowchart TD
    A[Scheduler or queue] --> B[Admin Lambda]
    B -->|service_role key| C[Supabase Admin or Data API]
    C --> D[(Postgres bypassing RLS)]

That is correct for trusted system jobs.

That is the wrong default for ordinary per-user endpoints.


Local Development Workflow

1. Start local Supabase

npx supabase init
npx supabase start
npx supabase db reset

2. Start the frontend

cd apps/web
npm install
npm run dev

3. Run the Lambda API locally

Using AWS SAM is a practical option:

cd apps/api
npm install
npm run build
sam build
sam local start-api

4. Point the frontend at local services

For example:

VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_PUBLISHABLE_KEY=<local-publishable-or-anon-key>
VITE_API_BASE_URL=http://127.0.0.1:3000

Local environment picture

flowchart LR
    A[Local Browser] --> B[Vite Dev Server]
    B --> C[SAM Local Lambda API]
    B --> D[Local Supabase Auth]
    C --> D
    D --> E[(Local Postgres)]
    D --> F[Local Studio]
    D --> G[Mailpit]

How User Data Should Flow

This is the cleanest pattern.

Sign-up time

Request time

Write time

Data ownership diagram

flowchart TD
    A[Browser form fields] --> B[Supabase Auth signUp]
    B --> C[auth.users]
    C --> D[Trigger creates public.profiles]
    E[Browser API request] --> F[Lambda verify JWT]
    F --> G[claims.sub]
    G --> H[Insert or query notes with user context]
    H --> I[(Postgres + RLS)]

Why this is the right split


Common Mistakes in This Stack

1. Using a privileged key for normal user traffic

This is the biggest mistake.

If user endpoints use a privileged key, RLS is no longer the thing protecting your data.

2. Trusting user_id from the browser

Never do this:

{
  "user_id": "someone-else-id",
  "title": "bad idea"
}

Always derive the user from the verified JWT.

3. Treating JWT claims as your main profile store

Use JWT claims for identity context.

Use database tables for real user data.

4. Making authorization decisions only in frontend code

Frontend checks are UX hints.

RLS is the real gate.

5. Forgetting CORS and preview-domain behavior

Cross-vendor frontends and APIs make this easy to miss.

6. Mixing old and new key terminology carelessly

Supabase now recommends publishable and secret API keys. Many older examples still show anon and service_role.

Be explicit about which kind of key you are actually using.


When to Choose a Different Infra Mix

This stack is strong, but it is not always the best default.

Choose something simpler if your needs are simpler.

If your API is mostly CRUD over your own tables

A simpler option is:

That removes Lambda entirely.

If you want server-side code but less cross-vendor complexity

A simpler option is:

That reduces CORS and vendor sprawl.

If you want everything inside AWS

A more AWS-native option is:

That may fit better for organizations with strict AWS-only requirements.

If you want very low-latency edge APIs

A strong alternative is:

That can feel lighter than API Gateway + Lambda for globally distributed APIs.


Variant 1: simplest product stack

Pick this if:

Variant 2: this guide’s stack

Pick this if:

Variant 3: AWS-heavy enterprise stack

Pick this if:

Variant 4: edge-first stack

Pick this if:


Official References

These are the main official docs used to shape this guide.