Skip to content

Frontend Integration Guide: Supabase Authentication

Date: November 13, 2025 Status: ⚠️ BREAKING CHANGES - Frontend updates required Impact: All authenticated API endpoints now require JWT tokens


Table of Contents

  1. Overview
  2. Breaking Changes
  3. Authentication Flow
  4. API Changes
  5. Implementation Guide
  6. Code Examples
  7. Testing Checklist

Overview

The backend has migrated from insecure query parameter authentication to secure JWT-based authentication using Supabase. This provides:

  • Better security: JWT tokens instead of phone numbers in URLs
  • Automatic expiry: Tokens expire after 1 hour
  • Refresh tokens: Seamless token renewal
  • Standard OAuth2 flow: Industry-standard authentication

What Changed

Before (insecure):

// ❌ Phone in query parameter - INSECURE!
fetch(`/api/user/profile?phone=${phone}`)

After (secure):

// ✅ JWT in Authorization header - SECURE!
fetch('/api/user/profile', {
  headers: {
    'Authorization': `Bearer ${accessToken}`
  }
})


Breaking Changes

Affected Endpoints

All user-specific endpoints now require Authorization header with JWT token:

Endpoint Method Change
GET /user/profile GET ❌ No more ?phone= parameter
✅ Requires Authorization: Bearer <token>
PUT /user/profile PUT ❌ No more ?phone= parameter
✅ Requires Authorization: Bearer <token>
GET /user/settings GET ❌ No more ?phone= parameter
✅ Requires Authorization: Bearer <token>
PUT /user/settings PUT ❌ No more ?phone= parameter
✅ Requires Authorization: Bearer <token>
GET /user/usage GET ❌ No more ?phone= parameter
✅ Requires Authorization: Bearer <token>
DELETE /user/account DELETE ❌ No more ?phone= parameter
✅ Requires Authorization: Bearer <token>

New Response Format from /auth/verify/confirm

Before:

{
  "success": true,
  "message": "Phone verified successfully"
}

After:

{
  "success": true,
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_in": 3600,
  "user": {
    "id": "uuid-here",
    "phone": "+15551234567",
    "supabase_id": "uuid-here"
  }
}


Authentication Flow

┌──────────┐        ┌──────────┐        ┌──────────┐        ┌──────────┐
│  User    │───────>│ Frontend │───────>│ Backend  │───────>│ Supabase │
│          │ Phone  │ (Vercel) │  OTP   │ (Railway)│  JWT   │   Auth   │
└──────────┘        └──────────┘        └──────────┘        └──────────┘
     │                    │                    │                    │
     │ Enter phone        │                    │                    │
     ├───────────────────>│                    │                    │
     │                    │ POST /auth/verify/start                │
     │                    ├───────────────────>│                    │
     │                    │                    │ Send OTP via Twilio│
     │                    │<───────────────────│                    │
     │                    │                    │                    │
     │ Receive SMS        │                    │                    │
     │ Enter OTP code     │                    │                    │
     ├───────────────────>│                    │                    │
     │                    │ POST /auth/verify/confirm              │
     │                    ├───────────────────>│                    │
     │                    │                    │ Create Supabase user
     │                    │                    ├───────────────────>│
     │                    │                    │<───────────────────│
     │                    │<───────────────────│ Return JWT tokens  │
     │                    │ {access_token,     │                    │
     │                    │  refresh_token}    │                    │
     │                    │                    │                    │
     │                    │ Store tokens in    │                    │
     │                    │ HTTP-only cookie   │                    │
     │                    │ or localStorage    │                    │
     │                    │                    │                    │
     │                    │ Future API requests│                    │
     │                    │ include:           │                    │
     │                    │ Authorization:     │                    │
     │                    │ Bearer <token>     │                    │
     │                    ├───────────────────>│                    │
     │                    │<───────────────────│                    │

API Changes

1. Phone Verification (No changes needed)

Start verification:

POST /auth/verify/start
Content-Type: application/json

{
  "phone": "+15551234567"
}

Response:

{
  "success": true,
  "message": "Verification code sent"
}

2. Confirm Verification (NEW response format)

Confirm code:

POST /auth/verify/confirm
Content-Type: application/json

{
  "phone": "+15551234567",
  "code": "123456"
}

NEW Response (with JWT tokens):

{
  "success": true,
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLWlkIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9.signature",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh-token-payload.signature",
  "expires_in": 3600,
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "phone": "+15551234567",
    "supabase_id": "auth-user-id-from-supabase"
  }
}

3. Authenticated Requests (NEW header required)

All user endpoints:

GET /user/profile
Authorization: Bearer <REDACTED>...

Response (same as before):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "phone": "+15551234567",
  "name": "John Doe",
  "pronouns": "he/him",
  "email": "john@example.com",
  ...
}


Implementation Guide

Step 1: Update Authentication Hook/Store

// lib/auth.ts or similar
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface AuthState {
  accessToken: string | null;
  refreshToken: string | null;
  user: User | null;
  setTokens: (accessToken: string, refreshToken: string, user: User) => void;
  clearTokens: () => void;
}

export const useAuth = create<AuthState>()(
  persist(
    (set) => ({
      accessToken: null,
      refreshToken: null,
      user: null,
      setTokens: (accessToken, refreshToken, user) =>
        set({ accessToken, refreshToken, user }),
      clearTokens: () =>
        set({ accessToken: null, refreshToken: null, user: null }),
    }),
    {
      name: 'auth-storage',
      // Optional: Use session storage instead of local storage
      // storage: createJSONStorage(() => sessionStorage),
    }
  )
);

Step 2: Update Phone Verification Component

// components/PhoneVerification.tsx
import { useState } from 'react';
import { useAuth } from '@/lib/auth';

export function PhoneVerification() {
  const [phone, setPhone] = useState('');
  const [code, setCode] = useState('');
  const [step, setStep] = useState<'phone' | 'code'>('phone');
  const { setTokens } = useAuth();

  const sendCode = async () => {
    const response = await fetch('/api/auth/verify/start', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ phone }),
    });

    if (response.ok) {
      setStep('code');
    }
  };

  const verifyCode = async () => {
    const response = await fetch('/api/auth/verify/confirm', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ phone, code }),
    });

    if (response.ok) {
      const data = await response.json();

      // ✅ NEW: Store JWT tokens
      setTokens(data.access_token, data.refresh_token, data.user);

      // Redirect to dashboard or home
      router.push('/dashboard');
    }
  };

  // ... rest of component
}

Step 3: Create API Client with Auto-Authentication

// lib/api-client.ts
import { useAuth } from './auth';

export class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string = process.env.NEXT_PUBLIC_API_URL!) {
    this.baseUrl = baseUrl;
  }

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    // Get access token from auth store
    const accessToken = useAuth.getState().accessToken;

    const headers = {
      'Content-Type': 'application/json',
      ...options.headers,
    };

    // ✅ Add Authorization header if token exists
    if (accessToken) {
      headers['Authorization'] = `Bearer ${accessToken}`;
    }

    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      ...options,
      headers,
    });

    // Handle 401 Unauthorized (token expired)
    if (response.status === 401) {
      // TODO: Implement token refresh logic
      // For now, clear tokens and redirect to login
      useAuth.getState().clearTokens();
      window.location.href = '/login';
      throw new Error('Unauthorized');
    }

    if (!response.ok) {
      throw new Error(`API error: ${response.statusText}`);
    }

    return response.json();
  }

  // User profile endpoints
  async getProfile() {
    return this.request<UserProfile>('/user/profile');
  }

  async updateProfile(data: Partial<UserProfile>) {
    return this.request<UserProfile>('/user/profile', {
      method: 'PUT',
      body: JSON.stringify(data),
    });
  }

  async getSettings() {
    return this.request<UserSettings>('/user/settings');
  }

  async updateSettings(data: Partial<UserSettings>) {
    return this.request<UserSettings>('/user/settings', {
      method: 'PUT',
      body: JSON.stringify(data),
    });
  }

  async getUsage() {
    return this.request<UsageData>('/user/usage');
  }

  async deleteAccount() {
    return this.request<{ success: boolean }>('/user/account', {
      method: 'DELETE',
    });
  }
}

export const apiClient = new ApiClient();

Step 4: Update Existing API Calls

Before:

// ❌ OLD - INSECURE!
const response = await fetch(`/api/user/profile?phone=${phone}`);

After:

// ✅ NEW - SECURE!
import { apiClient } from '@/lib/api-client';

const profile = await apiClient.getProfile();

// lib/api-client.ts (add this method)
private async refreshAccessToken(): Promise<string | null> {
  const refreshToken = useAuth.getState().refreshToken;

  if (!refreshToken) {
    return null;
  }

  try {
    const response = await fetch(`${this.baseUrl}/auth/refresh`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refresh_token: refreshToken }),
    });

    if (response.ok) {
      const data = await response.json();
      useAuth.getState().setTokens(
        data.access_token,
        data.refresh_token,
        data.user
      );
      return data.access_token;
    }
  } catch (error) {
    console.error('Token refresh failed:', error);
  }

  return null;
}

// Update request method to use refresh
private async request<T>(
  endpoint: string,
  options: RequestInit = {}
): Promise<T> {
  let accessToken = useAuth.getState().accessToken;

  // Try request with current token
  const headers = {
    'Content-Type': 'application/json',
    ...options.headers,
  };

  if (accessToken) {
    headers['Authorization'] = `Bearer ${accessToken}`;
  }

  let response = await fetch(`${this.baseUrl}${endpoint}`, {
    ...options,
    headers,
  });

  // If 401, try to refresh token and retry
  if (response.status === 401) {
    accessToken = await this.refreshAccessToken();

    if (accessToken) {
      // Retry with new token
      headers['Authorization'] = `Bearer ${accessToken}`;
      response = await fetch(`${this.baseUrl}${endpoint}`, {
        ...options,
        headers,
      });
    } else {
      // Refresh failed, redirect to login
      useAuth.getState().clearTokens();
      window.location.href = '/login';
      throw new Error('Unauthorized');
    }
  }

  if (!response.ok) {
    throw new Error(`API error: ${response.statusText}`);
  }

  return response.json();
}

Code Examples

Complete Login Flow

// pages/login.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';

export default function LoginPage() {
  const [phone, setPhone] = useState('');
  const [code, setCode] = useState('');
  const [step, setStep] = useState<'phone' | 'code'>('phone');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const router = useRouter();
  const { setTokens } = useAuth();

  const handleSendCode = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    try {
      const response = await fetch('/api/auth/verify/start', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phone }),
      });

      const data = await response.json();

      if (response.ok) {
        setStep('code');
      } else {
        setError(data.message || 'Failed to send verification code');
      }
    } catch (err) {
      setError('Network error. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  const handleVerifyCode = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    try {
      const response = await fetch('/api/auth/verify/confirm', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phone, code }),
      });

      const data = await response.json();

      if (response.ok) {
        // ✅ Store JWT tokens
        setTokens(data.access_token, data.refresh_token, data.user);

        // Redirect to dashboard
        router.push('/dashboard');
      } else {
        setError(data.message || 'Invalid verification code');
      }
    } catch (err) {
      setError('Network error. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-md space-y-8 p-8">
        <h1 className="text-2xl font-bold">
          {step === 'phone' ? 'Enter your phone number' : 'Enter verification code'}
        </h1>

        {error && (
          <div className="rounded bg-red-100 p-4 text-red-700">
            {error}
          </div>
        )}

        {step === 'phone' ? (
          <form onSubmit={handleSendCode} className="space-y-4">
            <input
              type="tel"
              value={phone}
              onChange={(e) => setPhone(e.target.value)}
              placeholder="+1 (555) 123-4567"
              className="w-full rounded border p-3"
              required
            />
            <button
              type="submit"
              disabled={loading}
              className="w-full rounded bg-blue-600 p-3 text-white hover:bg-blue-700 disabled:opacity-50"
            >
              {loading ? 'Sending...' : 'Send Code'}
            </button>
          </form>
        ) : (
          <form onSubmit={handleVerifyCode} className="space-y-4">
            <input
              type="text"
              value={code}
              onChange={(e) => setCode(e.target.value)}
              placeholder="123456"
              className="w-full rounded border p-3"
              maxLength={6}
              required
            />
            <button
              type="submit"
              disabled={loading}
              className="w-full rounded bg-blue-600 p-3 text-white hover:bg-blue-700 disabled:opacity-50"
            >
              {loading ? 'Verifying...' : 'Verify'}
            </button>
            <button
              type="button"
              onClick={() => setStep('phone')}
              className="w-full text-sm text-gray-600 hover:text-gray-900"
            >
              Change phone number
            </button>
          </form>
        )}
      </div>
    </div>
  );
}

Dashboard with Protected API Calls

// pages/dashboard.tsx
'use client';

import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { apiClient } from '@/lib/api-client';

export default function DashboardPage() {
  const [profile, setProfile] = useState(null);
  const [loading, setLoading] = useState(true);
  const { accessToken, clearTokens } = useAuth();
  const router = useRouter();

  useEffect(() => {
    // Redirect if not authenticated
    if (!accessToken) {
      router.push('/login');
      return;
    }

    // Fetch user profile
    loadProfile();
  }, [accessToken]);

  const loadProfile = async () => {
    try {
      const data = await apiClient.getProfile();
      setProfile(data);
    } catch (error) {
      console.error('Failed to load profile:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleLogout = () => {
    clearTokens();
    router.push('/login');
  };

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

  return (
    <div className="p-8">
      <h1 className="text-3xl font-bold">Dashboard</h1>

      {profile && (
        <div className="mt-4 space-y-2">
          <p><strong>Name:</strong> {profile.name || 'Not set'}</p>
          <p><strong>Phone:</strong> {profile.phone}</p>
          <p><strong>Pronouns:</strong> {profile.pronouns || 'Not set'}</p>
        </div>
      )}

      <button
        onClick={handleLogout}
        className="mt-6 rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
      >
        Logout
      </button>
    </div>
  );
}

Testing Checklist

Pre-Deployment Testing

  • Login flow works with phone verification
  • JWT tokens are stored after successful verification
  • API calls include Authorization header
  • Profile page loads user data correctly
  • Settings page loads and saves correctly
  • 401 errors trigger re-authentication
  • Logout clears tokens and redirects to login
  • Token refresh works (if implemented)

Manual Testing Steps

  1. Test login:

    1. Go to /login
    2. Enter phone number
    3. Receive SMS code
    4. Enter code
    5. Verify redirect to dashboard
    6. Check browser dev tools → Application → Local Storage
    7. Verify access_token is stored
    

  2. Test authenticated API:

    1. Open browser dev tools → Network tab
    2. Navigate to /dashboard
    3. Check API requests to /user/profile
    4. Verify Authorization header is present
    5. Verify response is 200 OK
    

  3. Test token expiry:

    1. Login successfully
    2. Wait 1 hour (or manually expire token)
    3. Try to load dashboard
    4. Verify redirect to login page
    

  4. Test logout:

    1. Login successfully
    2. Click logout button
    3. Verify redirect to login
    4. Verify tokens are cleared from storage
    5. Try to access /dashboard
    6. Verify redirect to login
    


Environment Variables

Add to Vercel (or .env.local):

# Backend API URL
NEXT_PUBLIC_API_URL=https://archety-backend-prod.up.railway.app

# Or for development
NEXT_PUBLIC_API_URL=https://archety-backend-dev.up.railway.app

Migration Strategy

Phase 1: Backward Compatibility (Optional)

If you need to support both old and new auth temporarily:

// lib/api-client.ts
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
  const accessToken = useAuth.getState().accessToken;
  const phone = useAuth.getState().user?.phone;

  const headers = {
    'Content-Type': 'application/json',
    ...options.headers,
  };

  // Try JWT first
  if (accessToken) {
    headers['Authorization'] = `Bearer ${accessToken}`;
  }
  // Fallback to query parameter (temporary, remove after migration)
  else if (phone && endpoint.includes('/user/')) {
    endpoint += `${endpoint.includes('?') ? '&' : '?'}phone=${encodeURIComponent(phone)}`;
  }

  // ... rest of request logic
}

Phase 2: Remove Fallback

After all users have migrated:

// Remove phone fallback code
// Only use JWT authentication

Troubleshooting

Issue: "401 Unauthorized" on API calls

Solution: 1. Check if accessToken is present in auth store 2. Check if Authorization header is being sent 3. Verify token hasn't expired (check exp claim) 4. Try logging out and logging in again

Issue: Token not being stored

Solution: 1. Check browser console for errors 2. Verify localStorage/sessionStorage is available 3. Check if Zustand persist is configured correctly

Issue: Infinite redirect loop

Solution: 1. Make sure login page doesn't require authentication 2. Check redirect logic in auth middleware 3. Verify token validation isn't failing silently


Support

For questions or issues: - Backend engineer: Check /docs/implementation/SUPABASE_RLS_AND_STORAGE.md - API documentation: Check /docs/api/ - Slack: #engineering-support


Last Updated: November 13, 2025 Backend Version: v3.6+ (Supabase Auth) Required Frontend Changes: CRITICAL - Update before next deployment