Skip to main content
ship before you scale

Auth Without Rolling Your Own: Supabase Auth, FastAPI JWT Validation, and Role-Based Access for Organizers and Vendors

7 min read Chapter 13 of 42

Auth Without Rolling Your Own

Authentication is not a feature. It is a liability. Every custom authentication system is a potential data breach. Password hashing, token management, session expiry, brute force protection, email verification, password reset flows: each of these is a security-critical component that must be implemented correctly the first time. A bug in password hashing is not a bug report. It is a security incident.

Supabase Auth handles all of this. The frontend calls supabase.auth.signUp() and receives a JWT. The backend validates that JWT with a three-line dependency function. The developer writes zero authentication code beyond these integration points.

The Feature

A market organizer signs up with email and password, receives a verification email, confirms their account, and logs in. They access the Marketflow dashboard. A vendor signs up separately, creates a vendor profile, and accesses the vendor portal. Both user types authenticate through the same Supabase project but have different roles that determine what they can see and do.

The Decision

Supabase Auth over Auth0, Clerk, or custom JWT. Supabase Auth is free for up to 50,000 monthly active users. Auth0’s free tier caps at 7,500 users. Clerk is more polished but costs $25/month after 10,000 users. Custom JWT implementation is the wrong answer at any price point because maintaining it is a permanent obligation.

Supabase Auth provides: email/password authentication, magic link login, OAuth with Google/GitHub/Apple, JWT issuance with custom claims, email verification, password reset, and rate limiting on auth endpoints. Building any of this from scratch takes weeks and produces a less secure result.

The Implementation

Frontend: Supabase Client

// frontend/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_ANON_KEY,
);

Frontend: Auth Components

// frontend/src/components/auth/SignUpForm.tsx
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { supabase } from "@/lib/supabase";
import { useState } from "react";

interface SignUpFormData {
  email: string;
  password: string;
  role: "organizer" | "vendor";
}

export function SignUpForm() {
  const [error, setError] = useState<string | null>(null);
  const [success, setSuccess] = useState(false);
  const { register, handleSubmit, formState: { errors, isSubmitting } } =
    useForm<SignUpFormData>();

  const onSubmit = async (data: SignUpFormData) => {
    setError(null);
    const { error: authError } = await supabase.auth.signUp({
      email: data.email,
      password: data.password,
      options: {
        data: {
          role: data.role,
        },
      },
    });

    if (authError) {
      setError(authError.message);
      return;
    }

    setSuccess(true);
  };

  if (success) {
    return (
      <p className="text-sm text-muted-foreground">
        Check your email for a verification link.
      </p>
    );
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <Label htmlFor="email">Email</Label>
        <Input
          id="email"
          type="email"
          {...register("email", { required: "Email is required" })}
        />
        {errors.email && (
          <p className="text-sm text-destructive">{errors.email.message}</p>
        )}
      </div>
      <div>
        <Label htmlFor="password">Password</Label>
        <Input
          id="password"
          type="password"
          {...register("password", {
            required: "Password is required",
            minLength: { value: 8, message: "Minimum 8 characters" },
          })}
        />
        {errors.password && (
          <p className="text-sm text-destructive">{errors.password.message}</p>
        )}
      </div>
      <div>
        <Label>I am a...</Label>
        <div className="flex gap-4 mt-2">
          <label className="flex items-center gap-2">
            <input type="radio" value="organizer" {...register("role", { required: true })} />
            Market Organizer
          </label>
          <label className="flex items-center gap-2">
            <input type="radio" value="vendor" {...register("role", { required: true })} />
            Vendor
          </label>
        </div>
      </div>
      <Button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Creating account..." : "Sign Up"}
      </Button>
      {error && <p className="text-sm text-destructive">{error}</p>}
    </form>
  );
}

Backend: JWT Validation

# backend/app/dependencies.py
import uuid
from dataclasses import dataclass

from fastapi import Depends, HTTPException, Request
from jose import JWTError, jwt

from app.config import settings


@dataclass
class CurrentUser:
    id: uuid.UUID
    email: str
    role: str


async def get_current_user(request: Request) -> CurrentUser:
    auth_header = request.headers.get("Authorization")
    if not auth_header or not auth_header.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing authorization header")

    token = auth_header.split(" ")[1]

    try:
        payload = jwt.decode(
            token,
            settings.supabase_jwt_secret,
            algorithms=["HS256"],
            audience="authenticated",
        )
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

    user_metadata = payload.get("user_metadata", {})

    return CurrentUser(
        id=uuid.UUID(payload["sub"]),
        email=payload.get("email", ""),
        role=user_metadata.get("role", "vendor"),
    )


async def require_organizer(
    current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
    if current_user.role != "organizer":
        raise HTTPException(status_code=403, detail="Organizer access required")
    return current_user


async def require_vendor(
    current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
    if current_user.role != "vendor":
        raise HTTPException(status_code=403, detail="Vendor access required")
    return current_user

Three dependency functions. get_current_user validates the JWT and extracts the user. require_organizer and require_vendor add role checks on top. Every protected endpoint uses one of these.

Using Auth Dependencies in Routers

# backend/app/routers/markets.py
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import get_db
from app.dependencies import CurrentUser, require_organizer
from app.schemas.market import MarketCreate, MarketRead

router = APIRouter(prefix="/api/markets", tags=["markets"])


@router.post("/", response_model=MarketRead, status_code=201)
async def create_market(
    body: MarketCreate,
    db: AsyncSession = Depends(get_db),
    current_user: CurrentUser = Depends(require_organizer),
) -> MarketRead:
    market = Market(
        organizer_id=current_user.id,
        name=body.name,
        description=body.description,
        location=body.location,
    )
    db.add(market)
    await db.commit()
    await db.refresh(market)
    return MarketRead.model_validate(market)


@router.get("/", response_model=list[MarketRead])
async def list_my_markets(
    db: AsyncSession = Depends(get_db),
    current_user: CurrentUser = Depends(require_organizer),
) -> list[MarketRead]:
    result = await db.execute(
        select(Market).where(Market.organizer_id == current_user.id)
    )
    markets = result.scalars().all()
    return [MarketRead.model_validate(m) for m in markets]

The require_organizer dependency on create_market means that vendors cannot create markets. The list_my_markets endpoint filters by current_user.id, ensuring organizers only see their own markets. Both checks are enforced by the dependency injection, not by manual if-statements in the endpoint body.

Profile Creation on First Login

# backend/app/routers/auth.py
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import get_db
from app.dependencies import CurrentUser, get_current_user
from app.models.organizer import OrganizerProfile
from app.models.vendor import Vendor

router = APIRouter(prefix="/api/auth", tags=["auth"])


@router.post("/profile")
async def ensure_profile(
    db: AsyncSession = Depends(get_db),
    current_user: CurrentUser = Depends(get_current_user),
) -> dict[str, str]:
    """Create user profile if it does not exist. Called after first login."""
    if current_user.role == "organizer":
        result = await db.execute(
            select(OrganizerProfile).where(
                OrganizerProfile.user_id == current_user.id
            )
        )
        if not result.scalar_one_or_none():
            profile = OrganizerProfile(
                user_id=current_user.id,
                display_name=current_user.email.split("@")[0],
                email=current_user.email,
            )
            db.add(profile)
            await db.commit()

    elif current_user.role == "vendor":
        result = await db.execute(
            select(Vendor).where(Vendor.user_id == current_user.id)
        )
        if not result.scalar_one_or_none():
            vendor = Vendor(
                user_id=current_user.id,
                business_name="",
                email=current_user.email,
                product_categories=[],
            )
            db.add(vendor)
            await db.commit()

    return {"status": "profile_ready"}

The frontend calls this endpoint after the first successful login. If the profile already exists, the endpoint is a no-op. If it does not exist, it creates one with sensible defaults. The alternative, a database trigger or Supabase webhook, adds complexity that is not justified at this scale.

The Trap

# TRAP: Trusting the role from the JWT without validation
async def get_current_user(request: Request) -> CurrentUser:
    # ... token validation ...
    return CurrentUser(
        id=uuid.UUID(payload["sub"]),
        email=payload.get("email", ""),
        role=payload.get("role", "vendor"),  # Role at top level of JWT
    )
# If a user can modify their JWT claims (they cannot with Supabase,
# but the code does not validate this), they can escalate to organizer.
# SAFE: Read role from user_metadata, which is set during signup
# and can only be modified by the service key (server-side)
async def get_current_user(request: Request) -> CurrentUser:
    # ... token validation ...
    user_metadata = payload.get("user_metadata", {})
    return CurrentUser(
        id=uuid.UUID(payload["sub"]),
        email=payload.get("email", ""),
        role=user_metadata.get("role", "vendor"),
    )

Supabase JWTs include user_metadata that is set during signup and can only be modified through the admin API (which requires the service key). Reading the role from user_metadata instead of a top-level claim ensures that users cannot change their own role.

The Cost

ComponentCost
Supabase Auth (free tier)$0 (50,000 MAU)
python-jose$0 (open source)
@supabase/supabase-js$0 (open source)

50,000 monthly active users is far beyond what Marketflow needs at launch. The Supabase Pro plan at $25/month provides 100,000 MAU and additional features (custom SMTP, audit logs). The upgrade becomes relevant only when the product has thousands of users.