# How to Allow Login Only for Invited Users in Supabase

- Tool: Supabase
- Difficulty: Beginner
- Time required: 10-15 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+
- Last updated: March 2026

## TL;DR

To restrict Supabase to invited users only, disable public signups in the Dashboard under Authentication > Providers > Email, then use supabase.auth.admin.inviteUserByEmail() from a server-side function or Edge Function to send invite emails. Only users who receive an invite email and click the link can create an account. The admin.inviteUserByEmail() method requires the service role key, so it must only be called from server-side code.

## Setting Up Invite-Only Authentication in Supabase

By default, anyone can sign up for a Supabase project with Auth enabled. For private applications, internal tools, or beta launches, you want to restrict access to invited users only. This tutorial shows you how to disable public signups, create an invite flow using the admin API, and build a simple admin panel for sending invitations. The invited user receives an email with a magic link that lets them set their password and access the app.

## Before you start

- A Supabase project with Auth and email provider enabled
- Custom SMTP configured (recommended to avoid the 2 emails/hour default limit)
- A server-side environment for calling the admin API (Edge Function, API route, or server component)
- @supabase/supabase-js v2+ installed

## Step-by-step guide

### 1. Disable public signups in the Dashboard

Go to the Supabase Dashboard, navigate to Authentication > Providers > Email. Toggle off the 'Allow new users to sign up' option (it may be labeled 'Enable sign ups' depending on your Dashboard version). When this is disabled, calling supabase.auth.signUp() from the client returns an error instead of creating a new account. Only the admin API (inviteUserByEmail) can create new users. This is the foundation of the invite-only system.

**Expected result:** Public signups are disabled. Calling signUp() from the client returns an error indicating signups are not allowed.

### 2. Create a Supabase admin client with the service role key

The inviteUserByEmail method is on supabase.auth.admin, which requires the service role key. Create a server-side admin client that uses this key. This client bypasses RLS and has full access to the auth system. Never expose the service role key in client-side code — only use it in Edge Functions, API routes, or server components.

```
// Server-side only! (e.g., Edge Function, API route)
import { createClient } from '@supabase/supabase-js'

const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  {
    auth: {
      autoRefreshToken: false,
      persistSession: false,
    },
  }
)
```

**Expected result:** A server-side Supabase admin client is created with the service role key.

### 3. Invite a user by email

Call supabase.auth.admin.inviteUserByEmail() with the user's email address. This creates a new user in auth.users with an unconfirmed status and sends them an invitation email. The email contains a magic link that, when clicked, confirms their account and redirects them to your app where they can set a password. You can optionally pass user metadata (like name or role) and a custom redirect URL.

```
// Invite a single user
const { data, error } = await supabaseAdmin.auth.admin.inviteUserByEmail(
  'newuser@company.com',
  {
    data: {
      full_name: 'Jane Smith',
      role: 'member',
    },
    redirectTo: 'https://your-app.com/welcome',
  }
)

if (error) {
  console.error('Invite failed:', error.message)
} else {
  console.log('Invitation sent to:', data.user.email)
}

// Invite multiple users
const emails = ['user1@company.com', 'user2@company.com', 'user3@company.com']

for (const email of emails) {
  const { error } = await supabaseAdmin.auth.admin.inviteUserByEmail(email)
  if (error) {
    console.error(`Failed to invite ${email}:`, error.message)
  }
}
```

**Expected result:** An invitation email is sent to the specified address, and a new user record is created in auth.users.

### 4. Build an invite endpoint with a Supabase Edge Function

Create an Edge Function that accepts an email address and sends an invitation. This function should verify that the caller is an authenticated admin before sending the invite. Check the caller's JWT for an admin role or verify against an admin list. This prevents unauthorized users from sending invitations even if they discover the endpoint.

```
// supabase/functions/invite-user/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'
import { corsHeaders } from '../_shared/cors.ts'

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  // Verify the caller is authenticated
  const authHeader = req.headers.get('Authorization')
  if (!authHeader) {
    return new Response(
      JSON.stringify({ error: 'Missing authorization header' }),
      { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  }

  // Create admin client
  const supabaseAdmin = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )

  // Verify caller is admin
  const supabaseUser = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!,
    { global: { headers: { Authorization: authHeader } } }
  )
  const { data: { user } } = await supabaseUser.auth.getUser()
  if (!user || user.app_metadata?.role !== 'admin') {
    return new Response(
      JSON.stringify({ error: 'Admin access required' }),
      { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  }

  const { email } = await req.json()
  const { data, error } = await supabaseAdmin.auth.admin.inviteUserByEmail(email)

  if (error) {
    return new Response(
      JSON.stringify({ error: error.message }),
      { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  }

  return new Response(
    JSON.stringify({ message: `Invitation sent to ${email}` }),
    { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
  )
})
```

**Expected result:** An Edge Function is deployed that sends invitations to specified email addresses when called by an admin.

### 5. Customize the invite email template

Go to the Supabase Dashboard, navigate to Authentication > Email Templates. Select the Invite User template. Customize the HTML to match your branding and include relevant information for your invitees. Use the {{ .ConfirmationURL }} template variable for the magic link. You can also include {{ .Email }} and {{ .SiteURL }}. The invite email is the first impression for new users, so make it clear and professional.

```
<!-- Example invite email template -->
<h2>You have been invited!</h2>
<p>You have been invited to join our application.</p>
<p>Click the link below to accept your invitation and set up your account:</p>
<p><a href="{{ .ConfirmationURL }}">Accept Invitation</a></p>
<p>This link expires in 24 hours.</p>
<p>If you did not expect this invitation, you can ignore this email.</p>
```

**Expected result:** The invite email template is customized and includes the confirmation URL magic link.

## Complete code example

File: `supabase/functions/invite-user/index.ts`

```typescript
// Supabase Edge Function: Invite User
// Sends an invitation email to a new user
// Only accessible by authenticated admin users

import { createClient } from 'npm:@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
}

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  try {
    const authHeader = req.headers.get('Authorization')
    if (!authHeader) {
      return new Response(
        JSON.stringify({ error: 'Authorization required' }),
        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      )
    }

    // Verify the caller is an admin
    const supabaseUser = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_ANON_KEY')!,
      { global: { headers: { Authorization: authHeader } } }
    )
    const { data: { user } } = await supabaseUser.auth.getUser()

    if (!user || user.app_metadata?.role !== 'admin') {
      return new Response(
        JSON.stringify({ error: 'Admin privileges required' }),
        { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      )
    }

    // Parse the request body
    const { email, metadata } = await req.json()
    if (!email) {
      return new Response(
        JSON.stringify({ error: 'Email is required' }),
        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      )
    }

    // Send the invitation using the admin client
    const supabaseAdmin = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    )

    const { data, error } = await supabaseAdmin.auth.admin.inviteUserByEmail(
      email,
      { data: metadata || {} }
    )

    if (error) {
      return new Response(
        JSON.stringify({ error: error.message }),
        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      )
    }

    return new Response(
      JSON.stringify({ message: `Invitation sent to ${email}`, user: data.user }),
      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  } catch (err) {
    return new Response(
      JSON.stringify({ error: 'Internal server error' }),
      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  }
})
```

## Common mistakes

- **Calling inviteUserByEmail with the anon key instead of the service role key** — undefined Fix: The admin API requires the service role key. Create a separate admin client with SUPABASE_SERVICE_ROLE_KEY for invite operations.
- **Forgetting to disable signups for OAuth providers after disabling email signups** — undefined Fix: Check each OAuth provider in Authentication > Providers and disable signups there too. Otherwise users can create accounts by signing in with Google/GitHub.
- **Hitting the 2 emails/hour SMTP rate limit when sending multiple invitations** — undefined Fix: Configure a custom SMTP provider in Dashboard > Authentication > SMTP Settings. Services like Resend or SendGrid support hundreds of emails per hour.

## Best practices

- Always disable signups for all auth providers (email and OAuth) when implementing invite-only access
- Use the service role key only in server-side code and never expose it to the client
- Verify the caller is an admin before processing invite requests in your Edge Function
- Configure custom SMTP before sending invitations to avoid the 2 emails/hour default limit
- Customize the invite email template to match your app's branding and set clear expectations
- Store the invite metadata (who invited whom, when) in a custom table for audit purposes
- Add CORS headers to your Edge Function so it can be called from your frontend

## Frequently asked questions

### Can invited users still use email/password login after accepting the invitation?

Yes. When a user clicks the invite link, they are prompted to set a password. After that, they can sign in with their email and password normally. The invite just creates the initial account.

### What happens if I invite someone who already has an account?

The inviteUserByEmail method returns an error if the email is already registered. Check for this error and handle it in your invite flow — you might want to show a message like 'This user already has an account.'

### How long does the invite link stay valid?

The default invite link expiration is 24 hours. You can configure this in Dashboard > Authentication > Auth Settings. After expiration, you need to resend the invitation.

### Can I invite users in bulk?

Yes. Loop through an array of email addresses and call inviteUserByEmail for each one. Add a small delay between calls if you have a large list to avoid rate limits.

### Does disabling signups affect existing users?

No. Disabling signups only prevents new accounts from being created via signUp(). Existing users can still sign in, and the admin API can still invite new users.

### Can I use inviteUserByEmail from the Supabase Dashboard?

Yes. Go to Authentication > Users and click 'Invite' or 'Add user' to send an invitation directly from the Dashboard without writing any code.

### Can RapidDev help build a complete invite-only authentication system?

Yes. RapidDev can build the full invite flow including an admin panel for sending invitations, custom email templates, role assignment during invite, and onboarding flows for new users accepting invitations.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-allow-login-only-for-invited-users-in-supabase
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-allow-login-only-for-invited-users-in-supabase
