Docs
Authentication & Authorization

Authentication & Authorization

Complete guide to the authentication and authorization system using NextAuth.js v5, magic links, OAuth providers, and role-based access control.

The YouTube Analyzer implements a comprehensive authentication and authorization system using NextAuth.js v5 with support for magic link authentication, OAuth providers, and role-based access control.

Authentication Architecture

graph TD
    A[User Login Request] --> B{Auth Method}
    B -->|Magic Link| C[Email Verification]
    B -->|OAuth| D[Google OAuth]
    C --> E[Resend Email]
    D --> F[Google APIs]
    E --> G[Magic Link Click]
    F --> H[OAuth Callback]
    G --> I[Session Creation]
    H --> I
    I --> J[JWT Token]
    J --> K[Dashboard Access]
    
    L[Middleware] --> M{Route Protection}
    M -->|Protected| N[Auth Check]
    M -->|Public| O[Allow Access]
    N -->|Valid| P[Allow Access]
    N -->|Invalid| Q[Redirect to Login]

NextAuth.js Configuration

Core Configuration

// auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import authConfig from "./auth.config";
import { prisma } from "@/lib/db";
import { getUserById } from "@/lib/user";
import { UserRole } from "@prisma/client";
 
export const {
  handlers: { GET, POST },
  auth,
  signIn,
  signOut,
} = NextAuth({
  pages: {
    signIn: "/login",
    error: "/login",
  },
  events: {
    async linkAccount({ user }) {
      // Mark email as verified when account is linked
      await prisma.user.update({
        where: { id: user.id },
        data: { emailVerified: new Date() }
      });
    },
  },
  callbacks: {
    async signIn({ user, account }) {
      // Allow OAuth without email verification
      if (account?.provider !== "resend") return true;
 
      const existingUser = await getUserById(user.id);
      
      // Prevent sign in without email verification for magic links
      if (!existingUser?.emailVerified) return false;
 
      return true;
    },
    async session({ token, session }) {
      if (token.sub && session.user) {
        session.user.id = token.sub;
      }
 
      if (token.role && session.user) {
        session.user.role = token.role as UserRole;
      }
 
      return session;
    },
    async jwt({ token }) {
      if (!token.sub) return token;
 
      const existingUser = await getUserById(token.sub);
 
      if (!existingUser) return token;
 
      token.role = existingUser.role;
 
      return token;
    },
  },
  adapter: PrismaAdapter(prisma),
  session: { strategy: "jwt" },
  ...authConfig,
});

Provider Configuration

// auth.config.ts
import type { NextAuthConfig } from "next-auth";
import Google from "next-auth/providers/google";
import Resend from "next-auth/providers/resend";
import { env } from "@/env.mjs";
import { sendVerificationRequest } from "@/lib/email";
 
export default {
  providers: [
    Google({
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    }),
    Resend({
      apiKey: env.RESEND_API_KEY,
      from: env.EMAIL_FROM,
      sendVerificationRequest,
    }),
  ],
} satisfies NextAuthConfig;

Authentication Methods

Magic links provide passwordless authentication via email verification.

Implementation

// lib/email.ts
export const sendVerificationRequest: EmailConfig["sendVerificationRequest"] =
  async ({ identifier, url, provider }) => {
    const user = await getUserByEmail(identifier);
    if (!user || !user.name) return;
 
    const userVerified = user?.emailVerified ? true : false;
    const authSubject = userVerified
      ? `Sign-in link for ${siteConfig.name}`
      : "Activate your account";
 
    try {
      const { data, error } = await resend.emails.send({
        from: provider.from,
        to: process.env.NODE_ENV === "development" 
          ? "delivered@resend.dev" 
          : identifier,
        subject: authSubject,
        react: MagicLinkEmail({
          firstName: user?.name as string,
          actionUrl: url,
          mailType: userVerified ? "login" : "register",
          siteName: siteConfig.name,
        }),
        headers: {
          "X-Entity-Ref-ID": new Date().getTime() + "",
        },
      });
 
      if (error || !data) {
        throw new Error(error?.message);
      }
    } catch (error) {
      throw new Error("Failed to send verification email.");
    }
  };

Client-Side Usage

// components/forms/user-auth-form.tsx
export function UserAuthForm({ type }: UserAuthFormProps) {
  const [isLoading, setIsLoading] = useState(false);
  const [isGoogleLoading, setIsGoogleLoading] = useState(false);
  
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(userAuthSchema),
  });
 
  async function onSubmit(data: FormData) {
    setIsLoading(true);
 
    const signInResult = await signIn("resend", {
      email: data.email.toLowerCase(),
      redirect: false,
      callbackUrl: searchParams?.get("from") || "/dashboard",
    });
 
    setIsLoading(false);
 
    if (!signInResult?.ok) {
      return toast.error("Something went wrong.", {
        description: "Your sign in request failed. Please try again.",
      });
    }
 
    return toast.success("Check your email", {
      description: "We sent you a login link. Be sure to check your spam too.",
    });
  }
 
  return (
    <div className="grid gap-6">
      <form onSubmit={handleSubmit(onSubmit)}>
        <div className="grid gap-2">
          <Label htmlFor="email">Email</Label>
          <Input
            id="email"
            placeholder="name@example.com"
            type="email"
            autoCapitalize="none"
            autoComplete="email"
            autoCorrect="off"
            disabled={isLoading || isGoogleLoading}
            {...register("email")}
          />
          {errors?.email && (
            <p className="px-1 text-xs text-red-600">
              {errors.email.message}
            </p>
          )}
        </div>
        <Button className="w-full" disabled={isLoading}>
          {isLoading && <Icons.spinner className="mr-2 h-4 w-4 animate-spin" />}
          Sign In with Email
        </Button>
      </form>
    </div>
  );
}

Google OAuth

OAuth integration for quick social authentication.

Configuration

// Google OAuth setup in auth.config.ts
Google({
  clientId: env.GOOGLE_CLIENT_ID,
  clientSecret: env.GOOGLE_CLIENT_SECRET,
  authorization: {
    params: {
      prompt: "consent",
      access_type: "offline",
      response_type: "code"
    }
  }
})

Client Implementation

// Google sign-in button
async function handleGoogleSignIn() {
  setIsGoogleLoading(true);
  
  try {
    await signIn("google", {
      callbackUrl: searchParams?.get("from") || "/dashboard",
    });
  } catch (error) {
    toast.error("Something went wrong.", {
      description: "Your sign in request failed. Please try again.",
    });
  } finally {
    setIsGoogleLoading(false);
  }
}
 
return (
  <Button
    variant="outline"
    onClick={handleGoogleSignIn}
    disabled={isLoading || isGoogleLoading}
  >
    {isGoogleLoading ? (
      <Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
    ) : (
      <Icons.google className="mr-2 h-4 w-4" />
    )}
    Google
  </Button>
);

Authorization System

Role-Based Access Control

The application implements a role-based authorization system with the following roles:

// prisma/schema.prisma
enum UserRole {
  USER
  ADMIN
}
 
model User {
  id            String    @id @default(cuid())
  name          String?
  email         String    @unique
  emailVerified DateTime?
  image         String?
  role          UserRole  @default(USER)
  
  // Subscription fields
  stripeCustomerId       String?   @unique @map(name: "stripe_customer_id")
  stripeSubscriptionId   String?   @unique @map(name: "stripe_subscription_id")
  stripePriceId          String?   @map(name: "stripe_price_id")
  stripeCurrentPeriodEnd DateTime? @map(name: "stripe_current_period_end")
  
  createdAt DateTime @default(now()) @map(name: "created_at")
  updatedAt DateTime @updatedAt @map(name: "updated_at")
 
  accounts  Account[]
  sessions  Session[]
  analyses  Analysis[]
  autoAnalyses AutoAnalysis[]
 
  @@map(name: "users")
}

Role Management

// lib/user.ts
export async function getUserById(id: string) {
  try {
    const user = await prisma.user.findUnique({
      where: { id },
    });
    return user;
  } catch {
    return null;
  }
}
 
export async function getUserByEmail(email: string) {
  try {
    const user = await prisma.user.findUnique({
      where: { email },
    });
    return user;
  } catch {
    return null;
  }
}
 
export async function updateUserRole(userId: string, role: UserRole) {
  try {
    await prisma.user.update({
      where: { id: userId },
      data: { role },
    });
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
}

Server Actions for User Management

// actions/update-user-role.ts
"use server";
 
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { userRoleSchema } from "@/lib/validations/user";
import { UserRole } from "@prisma/client";
import { revalidatePath } from "next/cache";
 
export type FormData = {
  role: UserRole;
};
 
export async function updateUserRole(userId: string, data: FormData) {
  try {
    const session = await auth();
 
    // Only admins can update user roles
    if (!session?.user || session.user.role !== "ADMIN") {
      throw new Error("Unauthorized");
    }
 
    const { role } = userRoleSchema.parse(data);
 
    await prisma.user.update({
      where: { id: userId },
      data: { role },
    });
 
    revalidatePath('/admin/users');
    return { status: "success" };
  } catch (error) {
    return { status: "error", message: error.message };
  }
}

Route Protection

Middleware Protection

// middleware.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export default auth((req: NextRequest & { auth: any }) => {
  const { pathname } = req.nextUrl;
  const isAuthenticated = !!req.auth;
 
  // Protected routes
  const protectedRoutes = [
    "/dashboard",
    "/settings", 
    "/billing",
    "/admin",
  ];
 
  // Admin-only routes
  const adminRoutes = ["/admin"];
 
  // Check if route requires authentication
  const isProtectedRoute = protectedRoutes.some(route => 
    pathname.startsWith(route)
  );
 
  // Redirect unauthenticated users
  if (isProtectedRoute && !isAuthenticated) {
    const signInUrl = new URL("/login", req.url);
    signInUrl.searchParams.set("callbackUrl", pathname);
    return NextResponse.redirect(signInUrl);
  }
 
  // Check admin access
  const isAdminRoute = adminRoutes.some(route => 
    pathname.startsWith(route)
  );
 
  if (isAdminRoute && req.auth?.user?.role !== "ADMIN") {
    return NextResponse.redirect(new URL("/dashboard", req.url));
  }
 
  // Redirect authenticated users from auth pages
  const authPages = ["/login", "/register"];
  if (authPages.includes(pathname) && isAuthenticated) {
    return NextResponse.redirect(new URL("/dashboard", req.url));
  }
 
  return NextResponse.next();
});
 
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

Page-Level Protection

// app/(protected)/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
 
export default async function DashboardPage() {
  const session = await auth();
 
  if (!session) {
    redirect("/login");
  }
 
  return (
    <div>
      <h1>Welcome, {session.user?.name}</h1>
      {/* Dashboard content */}
    </div>
  );
}

Component-Level Protection

// components/layout/user-account-nav.tsx
"use client";
 
import { useSession } from "next-auth/react";
import { UserRole } from "@prisma/client";
 
export function UserAccountNav() {
  const { data: session } = useSession();
 
  if (!session) {
    return null;
  }
 
  const isAdmin = session.user?.role === UserRole.ADMIN;
 
  return (
    <DropdownMenu>
      <DropdownMenuTrigger>
        <UserAvatar user={session.user} />
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuItem asChild>
          <Link href="/dashboard">Dashboard</Link>
        </DropdownMenuItem>
        <DropdownMenuItem asChild>
          <Link href="/settings">Settings</Link>
        </DropdownMenuItem>
        {isAdmin && (
          <DropdownMenuItem asChild>
            <Link href="/admin">Admin Panel</Link>
          </DropdownMenuItem>
        )}
        <DropdownMenuSeparator />
        <DropdownMenuItem onClick={() => signOut()}>
          Sign out
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Subscription-Based Authorization

Subscription Checks

// lib/subscription.ts
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { subscriptionPlans } from "@/config/subscriptions";
 
export async function getUserSubscriptionPlan(userId: string) {
  const user = await prisma.user.findFirst({
    where: { id: userId },
    select: {
      stripeSubscriptionId: true,
      stripeCurrentPeriodEnd: true,
      stripePriceId: true,
      stripeCustomerId: true,
    },
  });
 
  if (!user) {
    throw new Error("User not found");
  }
 
  // Check if user is on a paid plan
  const isPaid = 
    user.stripePriceId &&
    user.stripeCurrentPeriodEnd?.getTime()! + 86_400_000 > Date.now();
 
  const plan = isPaid
    ? subscriptionPlans.find(plan => plan.stripePriceId === user.stripePriceId)
    : subscriptionPlans[0]; // Free plan
 
  return {
    ...plan,
    ...user,
    stripeCurrentPeriodEnd: user.stripeCurrentPeriodEnd?.getTime(),
    isPaid,
  };
}
 
export async function checkSubscriptionAccess(
  userId: string,
  requiredPlan: string = "FREE"
): Promise<boolean> {
  const subscription = await getUserSubscriptionPlan(userId);
  
  const planHierarchy = ["FREE", "HOBBYIST", "CREATOR", "GROWTH"];
  const userPlanIndex = planHierarchy.indexOf(subscription.name?.toUpperCase() || "FREE");
  const requiredPlanIndex = planHierarchy.indexOf(requiredPlan);
  
  return userPlanIndex >= requiredPlanIndex;
}

Feature Access Control

// lib/features.ts
export interface FeatureAccess {
  maxAnalysesPerMonth: number;
  maxVideosPerAnalysis: number;
  autoAnalysis: boolean;
  apiAccess: boolean;
  prioritySupport: boolean;
}
 
export function getFeatureAccess(subscriptionPlan: string): FeatureAccess {
  const features: Record<string, FeatureAccess> = {
    FREE: {
      maxAnalysesPerMonth: 3,
      maxVideosPerAnalysis: 5,
      autoAnalysis: false,
      apiAccess: false,
      prioritySupport: false,
    },
    HOBBYIST: {
      maxAnalysesPerMonth: 25,
      maxVideosPerAnalysis: 25,
      autoAnalysis: false,
      apiAccess: false,
      prioritySupport: false,
    },
    CREATOR: {
      maxAnalysesPerMonth: 100,
      maxVideosPerAnalysis: 50,
      autoAnalysis: true,
      apiAccess: false,
      prioritySupport: false,
    },
    GROWTH: {
      maxAnalysesPerMonth: -1, // Unlimited
      maxVideosPerAnalysis: 100,
      autoAnalysis: true,
      apiAccess: true,
      prioritySupport: true,
    },
  };
 
  return features[subscriptionPlan] || features.FREE;
}

API Route Protection

// app/api/analysis/route.ts
import { auth } from "@/auth";
import { checkSubscriptionAccess } from "@/lib/subscription";
 
export async function POST(req: Request) {
  try {
    const session = await auth();
 
    if (!session?.user?.id) {
      return new Response("Unauthorized", { status: 401 });
    }
 
    // Check if user has access to create analyses
    const hasAccess = await checkSubscriptionAccess(session.user.id, "FREE");
    
    if (!hasAccess) {
      return new Response("Subscription required", { status: 403 });
    }
 
    // Check usage limits
    const subscription = await getUserSubscriptionPlan(session.user.id);
    const features = getFeatureAccess(subscription.name || "FREE");
    
    // Count current month's analyses
    const currentMonthAnalyses = await prisma.analysis.count({
      where: {
        userId: session.user.id,
        createdAt: {
          gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
        },
      },
    });
 
    if (features.maxAnalysesPerMonth !== -1 && 
        currentMonthAnalyses >= features.maxAnalysesPerMonth) {
      return new Response("Monthly analysis limit reached", { status: 429 });
    }
 
    // Process analysis request...
    
  } catch (error) {
    return new Response("Internal Server Error", { status: 500 });
  }
}

Session Management

Client-Side Session Handling

// providers/session-provider.tsx
"use client";
 
import { SessionProvider } from "next-auth/react";
 
interface ProvidersProps {
  children: React.ReactNode;
}
 
export function Providers({ children }: ProvidersProps) {
  return <SessionProvider>{children}</SessionProvider>;
}

Session Usage in Components

// components/dashboard/user-info.tsx
"use client";
 
import { useSession } from "next-auth/react";
 
export function UserInfo() {
  const { data: session, status } = useSession();
 
  if (status === "loading") {
    return <div>Loading...</div>;
  }
 
  if (status === "unauthenticated") {
    return <div>Not signed in</div>;
  }
 
  return (
    <div>
      <p>Signed in as {session?.user?.email}</p>
      <p>Role: {session?.user?.role}</p>
    </div>
  );
}

Security Best Practices

Environment Security

# Required environment variables
NEXTAUTH_SECRET=your_nextauth_secret_min_32_chars
NEXTAUTH_URL=https://yourdomain.com
 
# OAuth providers
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
 
# Email provider
RESEND_API_KEY=re_your_resend_api_key
EMAIL_FROM=noreply@yourdomain.com

CSRF Protection

NextAuth.js provides built-in CSRF protection through:

  • CSRF tokens in forms
  • Same-site cookie attributes
  • State parameter validation for OAuth

Session Security

// auth.ts configuration
export const authOptions = {
  session: {
    strategy: "jwt",
    maxAge: 30 * 24 * 60 * 60, // 30 days
  },
  cookies: {
    sessionToken: {
      name: `next-auth.session-token`,
      options: {
        httpOnly: true,
        sameSite: "lax",
        path: "/",
        secure: process.env.NODE_ENV === "production",
      },
    },
  },
};

Troubleshooting

Common Issues

  1. Magic link not received: Check spam folder, verify email configuration
  2. OAuth redirect errors: Verify OAuth app configuration and callback URLs
  3. Session not persisting: Check cookie settings and domain configuration
  4. Role updates not reflecting: Clear session cache or re-authenticate

Debug Helpers

// Debug session information
export function DebugSession() {
  const { data: session } = useSession();
  
  if (process.env.NODE_ENV === "development") {
    return (
      <pre className="bg-gray-100 p-4 rounded">
        {JSON.stringify(session, null, 2)}
      </pre>
    );
  }
  
  return null;
}

This authentication and authorization system provides secure, scalable user management with flexible role-based access control and subscription-aware feature gating throughout the YouTube Analyzer application.