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 Link Authentication
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.comCSRF 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
- Magic link not received: Check spam folder, verify email configuration
- OAuth redirect errors: Verify OAuth app configuration and callback URLs
- Session not persisting: Check cookie settings and domain configuration
- 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.