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¶
- Overview
- Breaking Changes
- Authentication Flow
- API Changes
- Implementation Guide
- Code Examples
- 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):
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:
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:
Response:
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:
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:
After:
// ✅ NEW - SECURE!
import { apiClient } from '@/lib/api-client';
const profile = await apiClient.getProfile();
Step 5: Implement Token Refresh (Optional but Recommended)¶
// 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
Authorizationheader - 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¶
-
Test login:
-
Test authenticated API:
-
Test token expiry:
-
Test logout:
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:
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