Integrate Supabase Auth with AWS API Gateway using Lambda Authorizer (React Implementation)

While working on one of my recent projects, I needed to connect a React application with several AWS Lambda functions through AWS API Gateway. The goal was to offload compute-intensive tasks that only authenticated users should access. Since I was using Supabase for authentication, I had to find a way to validate Supabase sessions on the API Gateway level. The solution was to create a custom Lambda authorizer.

If you’ve worked with Supabase before, you know it provides a great authentication system out of the box. However, when it comes to securing AWS API Gateway endpoints with Supabase sessions, there isn’t much documentation out there. I’ll show you how I implemented this connection step by step.

Prerequisites

  • AWS account with access to:
    • Lambda
    • API Gateway
    • Parameter Store
  • Supabase project already set up
  • Basic understanding of AWS services, IAM roles and policies, and Node.js

Step 1: Create Lambda Layer

Before creating our authorizer, we need to set up a Lambda Layer that includes the Supabase client library. The layer structure is important, it must follow a specific path pattern for Lambda to properly load the dependencies:

# Create the layer directory structure
mkdir supabase-js-layer
cd supabase-js-layer
mkdir -p nodejs/node_modules

# Initialize package.json in the nodejs directory
cd nodejs
npm init -y

# Install the Supabase client
npm install @supabase/supabase-js

# Go back and create the zip file
cd ..
zip -r supabase-js-layer.zip nodejs/

Note: Make sure you’re creating the layer ZIP file from inside the supabase-js-layer directory to maintain the correct path structure.

Step 2: Store Supabase Credentials

Another thing before creating our Lambda authorizer, is to securely store our Supabase credentials in AWS Parameter Store. This is more secure than hardcoding them directly in the lambda function.

Head to your Supabase project dashboard and get these values:

  • Project URL (Found in Project Settings > API)
  • Service Role Key (Found in Project Settings > API > Project API Keys)

Now, create two parameters in AWS Parameter Store:

  • One for your Supabase Project URL
  • One for your Service Role Key

Make sure to store them as SecureString type parameters for added security.

Step 3: Create the Lambda Authorizer

Now let’s create our Lambda authorizer that will validate Supabase sessions and authorize API requests. Here’s the complete code:

import { createClient } from '@supabase/supabase-js';
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';

// Initialize clients outside handler for reuse
const ssm = new SSMClient({});
const parameterCache = new Map();
let supabaseInstance = null;

async function getParameter(name) {
    if (parameterCache.has(name)) {
        return parameterCache.get(name);
    }

    const command = new GetParameterCommand({
        Name: name,
        WithDecryption: true
    });

    const response = await ssm.send(command);
    const value = response.Parameter?.Value;

    if (!value) {
        throw new Error(`Parameter ${name} not found`);
    }

    parameterCache.set(name, value);
    return value;
}

async function getSupabaseClient() {
    if (supabaseInstance) return supabaseInstance;

    const env = process.env.ENVIRONMENT || 'prod';

    const [projectUrl, serviceRole] = await Promise.all([
        getParameter(`SUPABASE_PROJECT_URL`),
        getParameter(`SUPABASE_SERVICE_ROLE_KEY`)
    ]);

    supabaseInstance = createClient(projectUrl, serviceRole, {
        auth: {
            autoRefreshToken: false,
            persistSession: false
        }
    });

    return supabaseInstance;
}

export const handler = async (event) => {
    console.log('Authorizer Event:', JSON.stringify(event, null, 2));
    
    try {
        let authHeader = event.type === 'TOKEN' 
            ? event.authorizationToken
            : event.headers?.authorization;

        if (!authHeader) {
            console.warn('No authorization header present');
            return { isAuthorized: false };
        }

        const token = authHeader.replace('Bearer ', '');
        const supabase = await getSupabaseClient();
        
        // Validate the session
        const { data: { user }, error } = await supabase.auth.getUser(token);

        if (error || !user) {
            console.warn('Session validation failed:', error?.message);
            return { isAuthorized: false };
        }

        // Return authorization result with user context
        return {
            isAuthorized: true,
            context: {
                userId: user.id,
                authType: 'session',
                email: user.email
            }
        };

    } catch (error) {
        console.error('Authorization error:', error);
        return { isAuthorized: false };
    }
};

Few important notes about the Lambda authorizer code:

  • We store Parameter Store values and Supabase client outside the handler to persist them between Lambda invocations
  • The authorizer works with both TOKEN and HTTP API types
  • We use the service role key (not anon key) for token validation, make sure to update the Parameter Store path in the code to match your configuration
  • Attach both our custom Supabase layer and the AWS-managed Parameters-and-Secrets layer
  • Your Lambda role needs IAM permissions to read from Parameter Store

Frontend Implementation

For the frontend part, I’ll share a complete example using Vite + React. I prefer Vite. If you’re using Create React App or Next.js, the code will work the same way, you’ll just need to adjust the environment variables syntax (e.g., REACT_APP_ prefix for CRA).

import { useEffect, useState } from 'react';
import { createClient } from '@supabase/supabase-js';

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

// Simple wrapper for API calls
const api = {
  async call(endpoint, options = {}) {
    const { data: { session } } = await supabase.auth.getSession();

    if (!session?.access_token) {
      throw new Error('Not authenticated');
    }

    const response = await fetch(`${import.meta.env.VITE_API_URL}${endpoint}`, {
      ...options,
      headers: {
        'Authorization': `Bearer ${session.access_token}`,
        'Content-Type': 'application/json',
        ...options.headers,
      },
    });

    if (!response.ok) {
      throw new Error('API request failed');
    }

    return response.json();
  }
};

function App() {
  const [session, setSession] = useState(null);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [email, setEmail] = useState('');
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // Get initial session
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session);
    });

    // Listen for auth changes
    const { data } = supabase.auth.onAuthStateChange(
      (event, session) => {
        setSession(session);
      }
    );

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

  // Example of making an authenticated API call
  async function fetchProtectedData() {
    try {
      const result = await api.call('/protected-endpoint', {
        method: 'POST',
        body: JSON.stringify({
          // Your request data
        })
      });
      setData(result);
    } catch (error) {
      setError(error.message);
      if (error.message === 'Not authenticated') {
        // Handle authentication error
        // Maybe redirect to login page
      }
    }
  }

  // Email login
  const handleLogin = async (e) => {
    e.preventDefault();
    try {
      setLoading(true);
      const { error } = await supabase.auth.signInWithOtp({
        email,
        options: {
          emailRedirectTo: window.location.origin
        }
      });
      if (error) throw error;
      alert('Check your email for the login link!');
    } catch (error) {
      alert(error.message);
    } finally {
      setLoading(false);
      setEmail('');
    }
  };

  return (
    <div>
      {!session ? (
        <div>
          <p>Please log in to continue</p>
          <form onSubmit={handleLogin}>
            <input
              type="email"
              placeholder="Your email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
            />
            <button type="submit" disabled={loading}>
              {loading ? 'Sending magic link...' : 'Send magic link'}
            </button>
          </form>
        </div>
      ) : (
        <div>
          <div>Logged in as {session.user.email}</div>
          <button onClick={fetchProtectedData}>
            Fetch Protected Data
          </button>
          <button onClick={() => supabase.auth.signOut()}>
            Sign Out
          </button>
          {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
          {error && <div style={{ color: 'red' }}>{error}</div>}
        </div>
      )}
    </div>
  );
}

export default App;

This implementation includes several key features:

  • Magic link authentication using Supabase’s OTP system
  • Automatic session management and auth state listening
  • A simple API wrapper that handles authentication headers
  • Basic error handling and loading states

Note: Make sure to create a .env file with your environment variables:

VITE_SUPABASE_URL=your_supabase_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
VITE_API_URL=your_api_gateway_url

Note for Remix users: When using Remix, you’ll typically handle the API calls server-side. Here’s a basic example:

import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'

export async function action({ request }) {
    const { supabase } = createServerClient(
        process.env.SUPABASE_URL,
        process.env.SUPABASE_ANON_KEY,
        {
            request,
            cookies: {
                getAll() {
                    return parseCookieHeader(request.headers.get('Cookie') ?? '')
                },
                setAll(cookiesToSet) {
                    cookiesToSet.forEach(({ name, value, options }) =>
                        headers.append('Set-Cookie', serializeCookieHeader(name, value, options))
                    )
                },
            },
        }
    );
    
    const { data: { session } } = await supabase.auth.getSession();
    
    if (!session) {
        throw new Error('Unauthorized');
    }
    
    // Make your API call here using session.access_token
}

That’s it! Your Lambda functions are now protected behind API Gateway using Supabase sessions, letting you safely handle heavy processing in Lambda and return results to your application, all while making sure only logged-in users can access these endpoints.