← Back to DevBytes

Shadcn UI Authentication: JWT, Sessions, and OAuth Integration

Introduction to Shadcn UI Authentication

Shadcn UI has rapidly become one of the most popular component libraries in the React ecosystem. Unlike traditional component libraries that ship as npm packages, Shadcn UI gives you the actual source code of each component, allowing you to copy, paste, and modify them directly in your project. While Shadcn UI itself does not include built-in authentication, its flexible architecture makes it an excellent foundation for building secure authentication flows using JWT tokens, session-based authentication, and OAuth integration.

This tutorial walks you through implementing a complete authentication system in a Next.js application using Shadcn UI components. We will cover JWT-based authentication, session management, and OAuth integration with popular providers like GitHub and Google.

What Is Shadcn UI Authentication?

Shadcn UI authentication refers to the practice of building authentication user interfaces and flows using the Shadcn UI component collection. Because Shadcn UI provides primitives such as buttons, inputs, labels, cards, dialogs, and form components, developers can construct polished login, registration, password reset, and OAuth consent screens without relying on a rigid auth UI kit.

The authentication logic itself is typically handled by libraries such as NextAuth.js (Auth.js), Lucia, Clerk, or custom implementations using jose for JWT signing. Shadcn UI simply provides the visual layer that sits on top of these authentication mechanisms.

Why It Matters

Prerequisites and Project Setup

Before we begin, ensure you have Node.js 18 or higher installed. We will use Next.js with the App Router, Tailwind CSS, and Shadcn UI. Run the following commands to scaffold a new project:

npx create-next-app@latest my-auth-app
cd my-auth-app
npx shadcn@latest init

During the Shadcn UI initialization, select your preferred style (default or New York), base color, and CSS variables. Once initialized, add the components we will need for our authentication forms:

npx shadcn@latest add button input label card form
npx shadcn@latest add sonner avatar separator

Next, install the authentication dependencies. We will use Auth.js (formerly NextAuth.js) for OAuth and session management, and jose for custom JWT handling:

npm install next-auth@beta jose
npm install -D @types/node

Create a .env.local file in the root of your project with the following environment variables:

AUTH_SECRET=your-super-secret-key-at-least-32-chars
AUTH_GITHUB_ID=your-github-oauth-client-id
AUTH_GITHUB_SECRET=your-github-oauth-client-secret
AUTH_GOOGLE_ID=your-google-oauth-client-id
AUTH_GOOGLE_SECRET=your-google-oauth-client-secret

Building the Authentication UI with Shadcn UI

Let us start by creating a reusable card-based layout for our authentication pages. Create a file at components/auth/auth-form-wrapper.tsx:

import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { ReactNode } from "react"

interface AuthFormWrapperProps {
  title: string
  description: string
  children: ReactNode
  footer?: ReactNode
}

export function AuthFormWrapper({ title, description, children, footer }: AuthFormWrapperProps) {
  return (
    <div className="flex min-h-screen items-center justify-center bg-background px-4">
      <Card className="w-full max-w-md">
        <CardHeader className="space-y-1">
          <CardTitle className="text-2xl font-bold">{title}</CardTitle>
          <CardDescription>{description}</CardDescription>
        </CardHeader>
        <CardContent>{children}</CardContent>
        {footer && <CardFooter className="flex flex-col gap-4">{footer}</CardFooter>}
      </Card>
    </div>
  )
}

Now let us create the login form component at components/auth/login-form.tsx. This form uses the Shadcn UI form component built on react-hook-form and Zod for validation:

"use client"

import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { toast } from "sonner"

const loginSchema = z.object({
  email: z.string().email("Please enter a valid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
})

type LoginValues = z.infer<typeof loginSchema>

export function LoginForm() {
  const [isLoading, setIsLoading] = useState(false)

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<LoginValues>({
    resolver: zodResolver(loginSchema),
  })

  async function onSubmit(values: LoginValues) {
    setIsLoading(true)
    try {
      const response = await fetch("/api/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(values),
      })

      if (!response.ok) {
        const error = await response.json()
        throw new Error(error.message || "Login failed")
      }

      toast.success("Logged in successfully")
      window.location.href = "/dashboard"
    } catch (error) {
      toast.error(error instanceof Error ? error.message : "Something went wrong")
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <div className="space-y-4">
      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
        <div className="space-y-2">
          <Label htmlFor="email">Email</Label>
          <Input id="email" type="email" placeholder="you@example.com" {...register("email")} />
          {errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
        </div>
        <div className="space-y-2">
          <Label htmlFor="password">Password</Label>
          <Input id="password" type="password" placeholder="********" {...register("password")} />
          {errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
        </div>
        <Button type="submit" className="w-full" disabled={isLoading}>
          {isLoading ? "Signing in..." : "Sign in"}
        </Button>
      </form>

      <div className="relative">
        <Separator />
        <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
          Or continue with
        </span>
      </div>

      <div className="grid grid-cols-2 gap-4">
        <Button variant="outline" onClick={() => signInWithProvider("github")}>
          GitHub
        </Button>
        <Button variant="outline" onClick={() => signInWithProvider("google")}>
          Google
        </Button>
      </div>
    </div>
  )
}

function signInWithProvider(provider: string) {
  window.location.href = `/api/auth/signin/${provider}`
}

Create the login page at app/login/page.tsx:

import { AuthFormWrapper } from "@/components/auth/auth-form-wrapper"
import { LoginForm } from "@/components/auth/login-form"
import Link from "next/link"

export default function LoginPage() {
  return (
    <AuthFormWrapper
      title="Welcome back"
      description="Enter your credentials to access your account"
      footer={
        <p className="text-sm text-muted-foreground">
          Don't have an account?{" "}
          <Link href="/register" className="text-primary hover:underline">
            Sign up
          </Link>
        </p>
      }
    >
      <LoginForm />
    </AuthFormWrapper>
  )
}

Implementing JWT-Based Authentication

JWT (JSON Web Tokens) provide a stateless way to handle authentication. The server issues a signed token containing user claims, and the client includes this token in subsequent requests. Let us implement a custom JWT authentication system using the jose library.

First, create a utility file at lib/jwt.ts for token generation and verification:

import { SignJWT, jwtVerify } from "jose"

const secretKey = process.env.AUTH_SECRET!
const encodedSecret = new TextEncoder().encode(secretKey)

export interface JWTPayload {
  userId: string
  email: string
  role: string
}

export async function signJWT(payload: JWTPayload, expiresIn: string = "7d"): Promise<string> {
  return new SignJWT({ ...payload })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime(expiresIn)
    .setIssuer("my-auth-app")
    .setAudience("my-auth-app-users")
    .sign(encodedSecret)
}

export async function verifyJWT(token: string): Promise<JWTPayload | null> {
  try {
    const { payload } = await jwtVerify(token, encodedSecret, {
      issuer: "my-auth-app",
      audience: "my-auth-app-users",
    })
    return payload as unknown as JWTPayload
  } catch (error) {
    console.error("JWT verification failed:", error)
    return null
  }
}

export async function refreshToken(token: string): Promise<string | null> {
  const payload = await verifyJWT(token)
  if (!payload) return null
  return signJWT({ userId: payload.userId, email: payload.email, role: payload.role })
}

Now create the login API route at app/api/auth/login/route.ts. This route validates credentials, issues a JWT, and sets it as an HTTP-only cookie:

import { NextRequest, NextResponse } from "next/server"
import { signJWT } from "@/lib/jwt"

// In production, replace this with a real database lookup
const mockUser = {
  id: "user_123",
  email: "user@example.com",
  password: "$2a$10$hashedPasswordHere",
  name: "Test User",
  role: "user",
}

export async function POST(request: NextRequest) {
  try {
    const { email, password } = await request.json()

    if (!email || !password) {
      return NextResponse.json(
        { message: "Email and password are required" },
        { status: 400 }
      )
    }

    // Replace with actual database query and bcrypt comparison
    if (email !== mockUser.email || password !== "password123") {
      return NextResponse.json(
        { message: "Invalid credentials" },
        { status: 401 }
      )
    }

    const token = await signJWT({
      userId: mockUser.id,
      email: mockUser.email,
      role: mockUser.role,
    })

    const response = NextResponse.json({
      user: { id: mockUser.id, email: mockUser.email, name: mockUser.name },
      message: "Login successful",
    })

    response.cookies.set("auth-token", token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      maxAge: 60 * 60 * 24 * 7, // 7 days
      path: "/",
    })

    return response
  } catch (error) {
    console.error("Login error:", error)
    return NextResponse.json(
      { message: "Internal server error" },
      { status: 500 }
    )
  }
}

Create a middleware file at middleware.ts in the project root to protect routes by verifying the JWT on every request:

import { NextRequest, NextResponse } from "next/server"
import { verifyJWT } from "@/lib/jwt"

const protectedRoutes = ["/dashboard", "/profile", "/settings"]
const authRoutes = ["/login", "/register"]

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  const token = request.cookies.get("auth-token")?.value

  const isProtectedRoute = protectedRoutes.some(route => pathname.startsWith(route))
  const isAuthRoute = authRoutes.some(route => pathname.startsWith(route))

  if (isProtectedRoute) {
    if (!token) {
      return NextResponse.redirect(new URL("/login", request.url))
    }

    const payload = await verifyJWT(token)
    if (!payload) {
      const response = NextResponse.redirect(new URL("/login", request.url))
      response.cookies.delete("auth-token")
      return response
    }

    // Add user info to headers for server components to read
    const requestHeaders = new Headers(request.headers)
    requestHeaders.set("x-user-id", payload.userId)
    requestHeaders.set("x-user-email", payload.email)
    requestHeaders.set("x-user-role", payload.role)

    return NextResponse.next({
      request: { headers: requestHeaders },
    })
  }

  if (isAuthRoute && token) {
    const payload = await verifyJWT(token)
    if (payload) {
      return NextResponse.redirect(new URL("/dashboard", request.url))
    }
  }

  return NextResponse.next()
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}

Session Management

While JWTs are stateless, you often need session management to track user state across your application. There are two primary approaches: cookie-based sessions and database-backed sessions. Let us implement a server-side session helper that reads the JWT from cookies and exposes user data to your components.

Create lib/session.ts:

import { cookies } from "next/headers"
import { verifyJWT, JWTPayload } from "./jwt"

export interface Session {
  user: {
    id: string
    email: string
    role: string
  }
  expires: Date
}

export async function getSession(): Promise<Session | null> {
  const cookieStore = await cookies()
  const token = cookieStore.get("auth-token")?.value

  if (!token) return null

  const payload = await verifyJWT(token)
  if (!payload) return null

  return {
    user: {
      id: payload.userId,
      email: payload.email,
      role: payload.role,
    },
    expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
  }
}

export async function getCurrentUser() {
  const session = await getSession()
  return session?.user ?? null
}

export async function requireAuth(): Promise<NonNullable<Awaited<ReturnType<typeof getSession>>>> {
  const session = await getSession()
  if (!session) {
    throw new Error("Unauthorized")
  }
  return session
}

export async function requireAdmin() {
  const session = await requireAuth()
  if (session.user.role !== "admin") {
    throw new Error("Forbidden: Admin access required")
  }
  return session
}

Use the session in a server component for your dashboard page at app/dashboard/page.tsx:

import { getCurrentUser } from "@/lib/session"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { redirect } from "next/navigation"

export default async function DashboardPage() {
  const user = await getCurrentUser()

  if (!user) {
    redirect("/login")
  }

  const initials = user.email
    .split("@")[0]
    .split(".")
    .map(part => part[0]?.toUpperCase())
    .join("")

  return (
    <div className="container mx-auto py-10">
      <div className="flex items-center gap-4 mb-8">
        <Avatar className="h-12 w-12">
          <AvatarImage src="" alt={user.email} />
          <AvatarFallback>{initials}</AvatarFallback>
        </Avatar>
        <div>
          <h1 className="text-2xl font-bold">Dashboard</h1>
          <p className="text-muted-foreground">{user.email}</p>
        </div>
      </div>

      <div className="grid gap-4 md:grid-cols-3">
        <Card>
          <CardHeader>
            <CardTitle>User ID</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-sm font-mono">{user.id}</p>
          </CardContent>
        </Card>
        <Card>
          <CardHeader>
            <CardTitle>Role</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-sm capitalize">{user.role}</p>
          </CardContent>
        </Card>
        <Card>
          <CardHeader>
            <CardTitle>Status</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-sm text-green-600">Active Session</p>
          </CardContent>
        </Card>
      </div>
    </div>
  )
}

For logging out, create app/api/auth/logout/route.ts:

import { NextResponse } from "next/server"

export async function POST() {
  const response = NextResponse.json({ message: "Logged out successfully" })
  response.cookies.delete("auth-token")
  return response
}

OAuth Integration with Auth.js

OAuth allows users to sign in using their existing accounts on platforms like GitHub, Google, or Twitter. Auth.js (NextAuth.js) is the most popular library for handling OAuth in Next.js applications. Let us integrate it alongside our JWT setup.

Create the Auth.js configuration at auth.ts in the project root:

import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import Google from "next-auth/providers/google"
import { signJWT } from "@/lib/jwt"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID,
      clientSecret: process.env.AUTH_GITHUB_SECRET,
    }),
    Google({
      clientId: process.env.AUTH_GOOGLE_ID,
      clientSecret: process.env.AUTH_GOOGLE_SECRET,
    }),
  ],
  pages: {
    signIn: "/login",
  },
  callbacks: {
    async jwt({ token, account, user }) {
      if (user && account) {
        // Issue our custom JWT when user signs in via OAuth
        const customToken = await signJWT({
          userId: user.id || token.sub!,
          email: user.email!,
          role: "user",
        })
        token.customToken = customToken
      }
      return token
    },
    async session({ session, token }) {
      if (token.customToken) {
        session.customToken = token.customToken as string
        session.user.id = token.sub!
      }
      return session
    },
  },
  session: {
    strategy: "jwt",
    maxAge: 60 * 60 * 24 * 7,
  },
})

Create the Auth.js API route handler at app/api/auth/[...nextauth]/route.ts:

import { handlers } from "@/auth"

export const { GET, POST } = handlers

Now update the login form to use Auth.js client-side sign-in. Create components/auth/oauth-buttons.tsx:

"use client"

import { Button } from "@/components/ui/button"
import { useState } from "react"

export function OAuthButtons() {
  const [loadingProvider, setLoadingProvider] = useState<string | null>(null)

  function handleOAuthSignIn(provider: string) {
    setLoadingProvider(provider)
    window.location.href = `/api/auth/signin/${provider}`
  }

  return (
    <div className="grid grid-cols-2 gap-4">
      <Button
        variant="outline"
        onClick={() => handleOAuthSignIn("github")}
        disabled={loadingProvider !== null}
      >
        {loadingProvider === "github" ? "Connecting..." : "GitHub"}
      </Button>
      <Button
        variant="outline"
        onClick={() => handleOAuthSignIn("google")}
        disabled={loadingProvider !== null}
      >
        {loadingProvider === "google" ? "Connecting..." : "Google"}
      </Button>
    </div>
  )
}

To synchronize the Auth.js session with our custom JWT cookie, create a server action at app/actions/sync-session.ts:

"use server"

import { auth } from "@/auth"
import { cookies } from "next/headers"

export async function syncSessionCookie() {
  const session = await auth()

  if (!session?.customToken) return

  const cookieStore = await cookies()
  cookieStore.set("auth-token", session.customToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 7,
    path: "/",
  })
}

Call this server action in a callback page after OAuth sign-in. Create app/auth/callback/page.tsx:

import { syncSessionCookie } from "@/app/actions/sync-session"
import { redirect } from "next/navigation"

export default async function AuthCallbackPage() {
  await syncSessionCookie()
  redirect("/dashboard")
}

Building a Registration Form

A complete authentication system needs a registration flow. Create components/auth/register-form.tsx:

"use client"

import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "sonner"

const registerSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Please enter a valid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
  confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords do not match",
  path: ["confirmPassword"],
})

type RegisterValues = z.infer<typeof registerSchema>

export function RegisterForm() {
  const [isLoading, setIsLoading] = useState(false)

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<RegisterValues>({
    resolver: zodResolver(registerSchema),
  })

  async function onSubmit(values: RegisterValues) {
    setIsLoading(true)
    try {
      const response = await fetch("/api/auth/register", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          name: values.name,
          email: values.email,
          password: values.password,
        }),
      })

      if (!response.ok) {
        const error = await response.json()
        throw new Error(error.message || "Registration failed")
      }

      toast.success("Account created successfully. Please sign in.")
      window.location.href = "/login"
    } catch (error) {
      toast.error(error instanceof Error ? error.message : "Something went wrong")
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div className="space-y-2">
        <Label htmlFor="name">Full Name</Label>
        <Input id="name" placeholder="John Doe" {...register("name")} />
        {errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
      </div>
      <div className="space-y-2">
        <Label htmlFor="email">Email</Label>
        <Input id="email" type="email" placeholder="you@example.com" {...register("email")} />
        {errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
      </div>
      <div className="space-y-2">
        <Label htmlFor="password">Password</Label>
        <Input id="password" type="password" placeholder="********" {...register("password")} />
        {errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
      </div>
      <div className="space-y-2">
        <Label htmlFor="confirmPassword">Confirm Password</Label>
        <Input id="confirmPassword" type="password" placeholder="********" {...register("confirmPassword")} />
        {errors.confirmPassword && <p className="text-sm text-destructive">{errors.confirmPassword.message}</p>}
      </div>
      <Button type="submit" className="w-full" disabled={isLoading}>
        {isLoading ? "Creating account..." : "Create account"}
      </Button>
    </form>
  )
}

Create the registration API route at app/api/auth/register/route.ts:

import { NextRequest, NextResponse } from "next/server"
import { hash } from "bcryptjs"
import { signJWT } from "@/lib/jwt"

export async function POST(request: NextRequest) {
  try {
    const { name, email, password } = await request.json()

    if (!name || !email || !password) {
      return NextResponse.json(
        { message: "All fields are required" },
        { status: 400 }
      )
    }

    // Check if user already exists (replace with database query)
    // const existingUser = await db.user.findUnique({ where: { email } })
    // if (existingUser) {
    //   return NextResponse.json({ message: "Email already registered" }, { status: 409 })
    // }

    const hashedPassword = await hash(password, 12)

    // Create user in database (replace with actual database insert)
    const newUser = {
      id: `user_${Date.now()}`,
      name,
      email,
      password: hashedPassword,
      role: "user",
    }

    const token = await signJWT({
      userId: newUser.id,
      email: newUser.email,
      role: newUser.role,
    })

    const response = NextResponse.json({
      user: { id: newUser.id, email: newUser.email, name: newUser.name },
      message: "Registration successful",
    })

    response.cookies.set("auth-token", token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      maxAge: 60 * 60 * 24 * 7,
      path: "/",
    })

    return response
  } catch (error) {
    console.error("Registration error:", error)
    return NextResponse.json(
      { message: "Internal server error" },
      { status: 500 }
    )
  }
}

Install bcryptjs for password hashing:

npm install bcryptjs
npm install -D @types/bcryptjs

Best Practices

Security Best Practices

JWT Best Practices

OAuth Best Practices

UI and UX Best Practices

Conclusion

Building authentication with Shadcn UI gives you the best of both worlds: beautiful, accessible, fully-owned UI components paired with a robust authentication backend of your choosing. By combining JWT tokens for stateless authentication, server-side session management for secure user context, and OAuth integration through Auth.js, you can create a production-ready authentication system that scales with your application. The key is to leverage Shadcn UI's composable components for the presentation layer while keeping your security logic in well-tested server-side code. Remember to follow security best practices around cookie handling, password hashing, and input validation, and your authentication system will provide a safe and seamless experience for your users.

— Ad —

Google AdSense will appear here after approval

← Back to all articles