ShipVeryFastShipVeryFast
Documentation

Authentication

ShipVeryFast wires up NextAuth.js with Google OAuth and magic-link email over Mailgun, backed by Supabase. This page covers how the config in libs/auth.ts is assembled, how it degrades gracefully on a fresh clone, and how to read and protect sessions.

NextAuth config in libs/auth.ts

The single source of truth for auth is the authOptions object exported from libs/auth.ts. It uses the JWT session strategy (session: { strategy: "jwt" }), is signed with NEXTAUTH_SECRET, and points NextAuth at the custom auth screens under app/login. The catch-all route handler at app/api/auth/[...nextauth]/route.ts simply passes authOptions to NextAuth() and re-exports it as both GET and POST, so the route handler and getServerSession() always read the same config.

// libs/auth.ts (shape)
export const authOptions: NextAuthOptions = {
  ...(hasSupabaseBackend
    ? { adapter: SupabaseAdapter({ url: env.SUPABASE_URL, secret: env.SUPABASE_SERVICE_ROLE_KEY }) }
    : {}),
  providers,                         // Google always; Email only with a real backend
  session: { strategy: "jwt" },
  secret: env.NEXTAUTH_SECRET,
  pages: {
    signIn: "/login",
    verifyRequest: "/login?check=email",
    error: "/login",
  },
};

Google OAuth provider

Google sign-in is always registered. The provider reads GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (both required env vars, validated by the Zod schema in libs/config.ts). Create an OAuth 2.0 Client ID in the Google Cloud Console under APIs & Services → Credentials, then add the NextAuth callback URL as an authorized redirect URI.

# Authorized redirect URIs to register in Google Cloud Console
http://localhost:3000/api/auth/callback/google      # local dev
https://your-domain.com/api/auth/callback/google    # production

The path is always /api/auth/callback/google, it is derived from the provider id, so it does not change.

Magic-link email over Mailgun

When a real backend is present, an EmailProvider is added to the front of the providers list. It sends through Mailgun SMTP (smtp.mailgun.org:587, authenticating as postmaster@${MAILGUN_DOMAIN} with MAILGUN_API_KEY) and mails from MAILGUN_FROM_EMAIL. Each send is throttled by the magicLinkRateLimiter, configured via RATE_LIMIT_MAGICLINK_MAX (default 5) and RATE_LIMIT_MAGICLINK_DURATION (default 3600 seconds).

// libs/auth.ts, sendVerificationRequest
sendVerificationRequest: async ({ identifier, url }) => {
  try {
    await magicLinkRateLimiter.consume(identifier);
  } catch {
    throw new Error("Too many verification requests, please try again later.");
  }
  const html = '<p>Sign in with this link: <a href="' + url + '">' + url + '</a></p>';
  await sendEmail({ to: identifier, subject: "Your sign-in link", html });
},
Magic-link sign-in needs a real adapter to persist verification tokens, so it only appears once a real Supabase backend is detected. On a fresh clone with placeholder keys, only Google sign-in is offered.

Graceful degradation without Supabase

NextAuth's EmailProvider hard-requires a database adapter. The placeholder values in .env.example (like your_supabase_service_role_key) do not make a usable adapter, so registering the email provider against them would throw EMAIL_REQUIRES_ADAPTER_ERROR on every /api/auth/session call, surfacing as a CLIENT_FETCH_ERROR in the browser. To avoid this, the config computes hasSupabaseBackend and only wires the adapter and email provider when real credentials are present.

// libs/auth.ts, the backend guard
const hasSupabaseBackend =
  !!env.SUPABASE_SERVICE_ROLE_KEY &&
  !env.SUPABASE_SERVICE_ROLE_KEY.startsWith("your_") &&
  env.SUPABASE_URL.startsWith("https://") &&
  !env.SUPABASE_URL.includes("example");

When this is false, the app boots adapter-less with Google OAuth and JWT sessions only, so a fresh clone runs cleanly out of the box.

The Supabase adapter path

Once real keys are in place and hasSupabaseBackend is true, the SupabaseAdapter from @next-auth/supabase-adapter is attached using SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY. The adapter persists verification tokens (and user records) to Supabase, which is what makes the magic-link flow work. The service-role key is only ever read on the server, see Database & data models for how supabase and supabaseAdmin differ.

Custom auth pages

All NextAuth redirects route back to a single branded page at app/login. The pagesblock maps three states to it: the sign-in screen, the "check your email" verify-request step, and the error target.

KeyTargetWhen
signIn/loginUnauthenticated user hits a protected route
verifyRequest/login?check=emailAfter a magic link is sent
error/loginSign-in or callback error

Reading the session

On the client, use the useSession() hook from next-auth/react (the app is wrapped in a session provider in app/providers.tsx). On the server, call getServerSession(authOptions), the repo also ships a thin getAuthSession() helper in app/utils/auth.ts that passes authOptions for you.

// Client component
"use client";
import { useSession } from "next-auth/react";

export function Greeting() {
  const { data: session } = useSession();
  return <p>Hello {session?.user?.email ?? "guest"}</p>;
}
// Server route / page
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/libs/auth";

export async function GET() {
  const session = await getServerSession(authOptions);
  if (!session?.user?.email) {
    return Response.json({ error: "Unauthorized" }, { status: 403 });
  }
  // ...authorized work
}

Protecting routes

Route gating happens in middleware.ts, which wraps the pipeline in withAuth from next-auth/middleware. Its authorized callback returns !!token, so any path in the matcher requires a valid JWT. The matcher currently covers /admin/:path*, /app/dashboard/:path*, /app/profile/:path*, and /api/:path*.

Note: the /dashboard/:path* matcher entry is commented out in middleware.ts for local preview (browsing without login). Restore it before committing if your dashboard should require auth.

For finer-grained admin access, API routes use the email allowlist gate in libs/admin.ts. isAdmin(session) fails closed: if ADMIN_EMAILS (a comma-separated list) is unset or empty, no user is treated as an admin. The security audit and alerts routes pair it with getServerSession.

// app/api/security/audit/route.ts (pattern)
const session = await getServerSession(authOptions);
if (!isAdmin(session)) {
  return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}

Adding another OAuth provider

To add a provider, import it from next-auth/providers/*, push it onto the providers array in libs/auth.ts, add its credentials to the Zod schema in libs/config.ts, and register the callback URL (/api/auth/callback/<id>) with the provider.

// libs/auth.ts
import GitHubProvider from "next-auth/providers/github";

const providers: NextAuthOptions["providers"] = [
  GoogleProvider({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET }),
  GitHubProvider({ clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET }),
];

Next steps