// Add authentication and authorization to routes, Server Actions, and API endpoints using Supabase Auth. Use when user needs "protect [route]", "add authentication", "require login", "add RBAC", "implement login/logout", or mentions auth, permissions, OAuth, API keys. Provides patterns for protected routes, Server Action auth checks, role-based access control (5 roles), OAuth providers (Google, GitHub), email verification, password strength, account lockout, and API key authentication. Do NOT use when building new features (use worldcrafter-feature-builder which can add auth), database-only changes (use worldcrafter-database-setup for RLS policies), routes without auth (use worldcrafter-route-creator), or testing only (use worldcrafter-test-generator).
| name | worldcrafter-auth-guard |
| description | Add authentication and authorization to routes, Server Actions, and API endpoints using Supabase Auth. Use when user needs "protect [route]", "add authentication", "require login", "add RBAC", "implement login/logout", or mentions auth, permissions, OAuth, API keys. Provides patterns for protected routes, Server Action auth checks, role-based access control (5 roles), OAuth providers (Google, GitHub), email verification, password strength, account lockout, and API key authentication. Do NOT use when building new features (use worldcrafter-feature-builder which can add auth), database-only changes (use worldcrafter-database-setup for RLS policies), routes without auth (use worldcrafter-route-creator), or testing only (use worldcrafter-test-generator). |
Version: 2.0.0 Last Updated: 2025-01-09
This skill provides patterns and tools for implementing authentication and authorization in WorldCrafter using Supabase Auth.
Related Skills:
worldcrafter-feature-builder - Feature-builder can include auth during creation, use auth-guard to add auth to existing featuresworldcrafter-route-creator - Route-creator creates pages, use auth-guard to protect themworldcrafter-database-setup - Database-setup creates RLS policies (database-level auth), auth-guard adds application-level authworldcrafter-test-generator - Use to test auth flows, protected routes, and permission checksExample Use Cases:
Use this skill when:
WorldCrafter uses Supabase Auth with cookie-based sessions:
users table syncs with Supabase auth.usersimport { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function ProtectedPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return (
<div>
<h1>Protected Content</h1>
<p>Welcome, {user.email}</p>
</div>
)
}
When to use:
// src/app/(protected)/layout.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return (
<div className="protected-layout">
{children}
</div>
)
}
When to use:
"use server"
import { createClient } from '@/lib/supabase/server'
import { prisma } from '@/lib/prisma'
export async function createPost(data: PostData) {
// 1. Authentication check
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Unauthorized' }
}
// 2. Perform action
const post = await prisma.post.create({
data: {
...data,
authorId: user.id
}
})
return { success: true, data: post }
}
When to use:
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Fetch user-specific data
const data = await prisma.post.findMany({
where: { authorId: user.id }
})
return NextResponse.json(data)
}
When to use:
import { createClient } from '@/lib/supabase/server'
import { prisma } from '@/lib/prisma'
import { redirect } from 'next/navigation'
async function requireRole(allowedRoles: string[]) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
// Get user role from database
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { role: true }
})
if (!dbUser || !allowedRoles.includes(dbUser.role)) {
redirect('/unauthorized')
}
return { user, role: dbUser.role }
}
// Usage in page
export default async function AdminPage() {
await requireRole(['ADMIN'])
return <div>Admin content</div>
}
When to use:
// src/middleware.ts
import { updateSession } from '@/lib/supabase/middleware'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
// Update auth session
const { response, user } = await updateSession(request)
// Protect routes
const protectedPaths = ['/dashboard', '/settings', '/admin']
const isProtectedPath = protectedPaths.some(path =>
request.nextUrl.pathname.startsWith(path)
)
if (isProtectedPath && !user) {
const redirectUrl = request.nextUrl.clone()
redirectUrl.pathname = '/login'
redirectUrl.searchParams.set('redirectTo', request.nextUrl.pathname)
return NextResponse.redirect(redirectUrl)
}
return response
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
When to use:
"use server"
export async function updatePost(id: string, data: PostData) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Unauthorized' }
}
// Check if user owns the post
const post = await prisma.post.findUnique({
where: { id },
select: { authorId: true }
})
if (!post || post.authorId !== user.id) {
return { success: false, error: 'Forbidden' }
}
// User is authorized, proceed
const updated = await prisma.post.update({
where: { id },
data
})
return { success: true, data: updated }
}
async function hasPermission(userId: string, permission: string): Promise<boolean> {
const userPermissions = await prisma.userPermission.findMany({
where: { userId },
select: { permission: true }
})
return userPermissions.some(p => p.permission === permission)
}
export async function deletePost(id: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Unauthorized' }
}
// Check permission
if (!await hasPermission(user.id, 'posts.delete')) {
return { success: false, error: 'Forbidden' }
}
await prisma.post.delete({ where: { id } })
return { success: true }
}
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const router = useRouter()
const supabase = createClient()
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setError('')
const { error } = await supabase.auth.signInWithPassword({
email,
password
})
if (error) {
setError(error.message)
} else {
router.push('/dashboard')
router.refresh()
}
}
return (
<form onSubmit={handleLogin}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
{error && <p>{error}</p>}
<button type="submit">Login</button>
</form>
)
}
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
export function LogoutButton() {
const router = useRouter()
const supabase = createClient()
async function handleLogout() {
await supabase.auth.signOut()
router.push('/login')
router.refresh()
}
return <button onClick={handleLogout}>Logout</button>
}
// In login page
const searchParams = new URLSearchParams(window.location.search)
const redirectTo = searchParams.get('redirectTo') || '/dashboard'
// After successful login
router.push(redirectTo)
// Server Component
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// Client Component
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function useAuth() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const supabase = createClient()
useEffect(() => {
const getUser = async () => {
const { data: { user } } = await supabase.auth.getUser()
setUser(user)
setLoading(false)
}
getUser()
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setUser(session?.user ?? null)
}
)
return () => {
subscription.unsubscribe()
}
}, [])
return { user, loading }
}
https://YOUR_PROJECT.supabase.co/auth/v1/callbackAdd to .env:
NEXT_PUBLIC_GOOGLE_CLIENT_ID="your-google-client-id"
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
async function handleEmailLogin(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
} else {
router.push('/dashboard')
router.refresh()
}
}
async function handleGoogleLogin() {
setError('')
setLoading(true)
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
access_type: 'offline',
prompt: 'consent',
},
},
})
if (error) {
setError(error.message)
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8">
<div>
<h2 className="text-center text-3xl font-bold">Sign in</h2>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<form onSubmit={handleEmailLogin} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in with Email'}
</Button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500">Or continue with</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleGoogleLogin}
disabled={loading}
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Sign in with Google
</Button>
<p className="text-center text-sm text-gray-600">
Don't have an account?{' '}
<a href="/signup" className="font-medium text-blue-600 hover:text-blue-500">
Sign up
</a>
</p>
</div>
</div>
)
}
https://YOUR_PROJECT.supabase.co/auth/v1/callbackAdd to .env:
NEXT_PUBLIC_GITHUB_CLIENT_ID="your-github-client-id"
async function handleGitHubLogin() {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
if (error) {
setError(error.message)
}
}
Add to login form:
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleGitHubLogin}
disabled={loading}
>
<svg className="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
Sign in with GitHub
</Button>
Create src/app/auth/callback/route.ts:
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url)
const code = requestUrl.searchParams.get('code')
if (code) {
const supabase = await createClient()
await supabase.auth.exchangeCodeForSession(code)
}
// Redirect to home page or original destination
const redirectTo = requestUrl.searchParams.get('redirectTo') || '/'
return NextResponse.redirect(new URL(redirectTo, requestUrl.origin))
}
'use client'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
export function LinkAccountButtons() {
const supabase = createClient()
async function linkGoogle() {
const { error } = await supabase.auth.linkIdentity({
provider: 'google',
})
if (error) {
console.error('Error linking Google account:', error)
}
}
async function linkGitHub() {
const { error } = await supabase.auth.linkIdentity({
provider: 'github',
})
if (error) {
console.error('Error linking GitHub account:', error)
}
}
return (
<div className="space-y-4">
<h3>Link Additional Accounts</h3>
<Button onClick={linkGoogle}>Link Google Account</Button>
<Button onClick={linkGitHub}>Link GitHub Account</Button>
</div>
)
}
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useRouter } from 'next/navigation'
export default function SignupPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
async function handleSignup(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/confirm`,
},
})
if (error) {
setError(error.message)
setLoading(false)
} else {
setSuccess(true)
setLoading(false)
}
}
if (success) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8">
<div className="rounded-md bg-green-50 p-4">
<h3 className="text-lg font-medium text-green-800">
Check your email
</h3>
<p className="mt-2 text-sm text-green-700">
We've sent a verification link to <strong>{email}</strong>.
Please check your inbox and click the link to verify your account.
</p>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => router.push('/login')}
>
Back to Login
</Button>
</div>
</div>
)
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8">
<div>
<h2 className="text-center text-3xl font-bold">Create an account</h2>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<form onSubmit={handleSignup} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
disabled={loading}
/>
<p className="mt-1 text-sm text-gray-500">
Minimum 12 characters, include uppercase, number, and special character
</p>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Creating account...' : 'Sign up'}
</Button>
</form>
<p className="text-center text-sm text-gray-600">
Already have an account?{' '}
<a href="/login" className="font-medium text-blue-600 hover:text-blue-500">
Sign in
</a>
</p>
</div>
</div>
)
}
Create src/app/auth/confirm/page.tsx:
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function ConfirmPage({
searchParams,
}: {
searchParams: { token_hash?: string; type?: string }
}) {
const supabase = await createClient()
if (searchParams.token_hash && searchParams.type) {
const { error } = await supabase.auth.verifyOtp({
token_hash: searchParams.token_hash,
type: searchParams.type as any,
})
if (!error) {
redirect('/dashboard')
}
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8">
<div className="rounded-md bg-yellow-50 p-4">
<h3 className="text-lg font-medium text-yellow-800">
Verification in progress
</h3>
<p className="mt-2 text-sm text-yellow-700">
Please wait while we verify your email...
</p>
</div>
</div>
</div>
)
}
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
export function ResendVerificationEmail() {
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
const supabase = createClient()
async function handleResend(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setMessage('')
const { error } = await supabase.auth.resend({
type: 'signup',
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/confirm`,
},
})
if (error) {
setMessage(error.message)
} else {
setMessage('Verification email sent! Check your inbox.')
}
setLoading(false)
}
return (
<form onSubmit={handleResend} className="space-y-4">
<h3 className="text-lg font-medium">Resend Verification Email</h3>
{message && (
<div className="rounded-md bg-blue-50 p-4">
<p className="text-sm text-blue-800">{message}</p>
</div>
)}
<div>
<label htmlFor="resend-email" className="block text-sm font-medium">
Email
</label>
<Input
id="resend-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
/>
</div>
<Button type="submit" disabled={loading}>
{loading ? 'Sending...' : 'Resend Verification Email'}
</Button>
</form>
)
}
"use server"
import { createClient } from '@/lib/supabase/server'
export async function checkEmailVerified(): Promise<boolean> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return false
}
// Check if email is verified
return user.email_confirmed_at !== null
}
Use in Server Component:
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { ResendVerificationEmail } from './ResendVerificationEmail'
export default async function ProtectedPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
if (!user.email_confirmed_at) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8">
<div className="rounded-md bg-yellow-50 p-4">
<h3 className="text-lg font-medium text-yellow-800">
Email not verified
</h3>
<p className="mt-2 text-sm text-yellow-700">
Please verify your email address to access this page.
</p>
</div>
<ResendVerificationEmail />
</div>
</div>
)
}
return <div>Protected content</div>
}
Add to prisma/schema.prisma:
model ApiKey {
id String @id @default(cuid())
userId String @map("user_id")
name String
key String @unique
lastUsedAt DateTime? @map("last_used_at")
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([key])
@@map("api_keys")
}
"use server"
import { createClient } from '@/lib/supabase/server'
import { prisma } from '@/lib/prisma'
import { randomBytes } from 'crypto'
export async function generateApiKey(name: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Unauthorized' }
}
// Generate secure random key
const key = `wc_${randomBytes(32).toString('hex')}`
const apiKey = await prisma.apiKey.create({
data: {
userId: user.id,
name,
key,
},
})
return { success: true, data: { key: apiKey.key, name: apiKey.name } }
}
export async function listApiKeys() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Unauthorized' }
}
const keys = await prisma.apiKey.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
lastUsedAt: true,
createdAt: true,
expiresAt: true,
// Don't return the actual key for security
},
orderBy: { createdAt: 'desc' },
})
return { success: true, data: keys }
}
export async function revokeApiKey(keyId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Unauthorized' }
}
await prisma.apiKey.delete({
where: {
id: keyId,
userId: user.id, // Ensure user owns the key
},
})
return { success: true }
}
Create src/lib/auth/api-key.ts:
import { prisma } from '@/lib/prisma'
import { NextRequest } from 'next/server'
export async function validateApiKey(
request: NextRequest
): Promise<{ valid: boolean; userId?: string }> {
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return { valid: false }
}
const key = authHeader.substring(7) // Remove 'Bearer '
const apiKey = await prisma.apiKey.findUnique({
where: { key },
select: {
id: true,
userId: true,
expiresAt: true,
},
})
if (!apiKey) {
return { valid: false }
}
// Check if expired
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
return { valid: false }
}
// Update last used timestamp
await prisma.apiKey.update({
where: { id: apiKey.id },
data: { lastUsedAt: new Date() },
})
return { valid: true, userId: apiKey.userId }
}
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { validateApiKey } from '@/lib/auth/api-key'
import { prisma } from '@/lib/prisma'
export async function GET(request: NextRequest) {
// Validate API key
const { valid, userId } = await validateApiKey(request)
if (!valid || !userId) {
return NextResponse.json(
{ error: 'Invalid or missing API key' },
{ status: 401 }
)
}
// Fetch user-specific data
const data = await prisma.world.findMany({
where: { ownerId: userId },
})
return NextResponse.json(data)
}
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { generateApiKey, listApiKeys, revokeApiKey } from './actions'
export function ApiKeyManager() {
const [keys, setKeys] = useState<any[]>([])
const [newKeyName, setNewKeyName] = useState('')
const [generatedKey, setGeneratedKey] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
loadKeys()
}, [])
async function loadKeys() {
const result = await listApiKeys()
if (result.success) {
setKeys(result.data)
}
}
async function handleGenerate(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
const result = await generateApiKey(newKeyName)
if (result.success) {
setGeneratedKey(result.data.key)
setNewKeyName('')
await loadKeys()
}
setLoading(false)
}
async function handleRevoke(keyId: string) {
if (!confirm('Are you sure you want to revoke this API key?')) {
return
}
await revokeApiKey(keyId)
await loadKeys()
}
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">API Keys</h2>
<p className="text-gray-600">
Manage your API keys for programmatic access
</p>
</div>
{generatedKey && (
<div className="rounded-md bg-green-50 p-4">
<h3 className="text-lg font-medium text-green-800">
API Key Generated
</h3>
<p className="mt-2 text-sm text-green-700">
Save this key securely. You won't be able to see it again.
</p>
<div className="mt-4 flex items-center space-x-2">
<code className="rounded bg-green-100 px-2 py-1 font-mono text-sm">
{generatedKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => {
navigator.clipboard.writeText(generatedKey)
}}
>
Copy
</Button>
</div>
<Button
className="mt-4"
size="sm"
variant="outline"
onClick={() => setGeneratedKey('')}
>
Done
</Button>
</div>
)}
<form onSubmit={handleGenerate} className="space-y-4">
<h3 className="text-lg font-medium">Generate New API Key</h3>
<div className="flex space-x-2">
<Input
type="text"
placeholder="Key name (e.g., Production API)"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
required
disabled={loading}
/>
<Button type="submit" disabled={loading}>
Generate
</Button>
</div>
</form>
<div>
<h3 className="text-lg font-medium">Your API Keys</h3>
<div className="mt-4 space-y-4">
{keys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between rounded-md border p-4"
>
<div>
<p className="font-medium">{key.name}</p>
<p className="text-sm text-gray-500">
Created: {new Date(key.createdAt).toLocaleDateString()}
{key.lastUsedAt && (
<span>
{' '}
• Last used:{' '}
{new Date(key.lastUsedAt).toLocaleDateString()}
</span>
)}
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => handleRevoke(key.id)}
>
Revoke
</Button>
</div>
))}
{keys.length === 0 && (
<p className="text-gray-500">No API keys yet</p>
)}
</div>
</div>
</div>
)
}
Create src/lib/auth/rate-limit.ts:
import { prisma } from '@/lib/prisma'
interface RateLimitResult {
allowed: boolean
remaining: number
resetAt: Date
}
export async function checkRateLimit(
apiKeyId: string,
limit: number = 100,
windowMs: number = 60000 // 1 minute
): Promise<RateLimitResult> {
const now = new Date()
const windowStart = new Date(now.getTime() - windowMs)
// Count requests in current window
const count = await prisma.apiRequest.count({
where: {
apiKeyId,
createdAt: {
gte: windowStart,
},
},
})
const allowed = count < limit
const remaining = Math.max(0, limit - count - 1)
const resetAt = new Date(now.getTime() + windowMs)
if (allowed) {
// Log this request
await prisma.apiRequest.create({
data: {
apiKeyId,
createdAt: now,
},
})
}
return { allowed, remaining, resetAt }
}
Add to schema:
model ApiRequest {
id String @id @default(cuid())
apiKeyId String @map("api_key_id")
createdAt DateTime @default(now()) @map("created_at")
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
@@index([apiKeyId, createdAt])
@@map("api_requests")
}
Use in API route:
import { validateApiKey } from '@/lib/auth/api-key'
import { checkRateLimit } from '@/lib/auth/rate-limit'
export async function GET(request: NextRequest) {
const { valid, userId } = await validateApiKey(request)
if (!valid) {
return NextResponse.json({ error: 'Invalid API key' }, { status: 401 })
}
// Check rate limit
const { allowed, remaining, resetAt } = await checkRateLimit(userId!, 100)
if (!allowed) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{
status: 429,
headers: {
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': resetAt.toISOString(),
},
}
)
}
// Process request
const data = await fetchData(userId!)
return NextResponse.json(data, {
headers: {
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': resetAt.toISOString(),
},
})
}
Create src/lib/schemas/password.ts:
import { z } from 'zod'
export const passwordSchema = z
.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(
/[^A-Za-z0-9]/,
'Password must contain at least one special character'
)
export const passwordConfirmSchema = z
.object({
password: passwordSchema,
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
export type PasswordData = z.infer<typeof passwordSchema>
export type PasswordConfirmData = z.infer<typeof passwordConfirmSchema>
Create src/components/auth/PasswordStrength.tsx:
'use client'
interface PasswordStrengthProps {
password: string
}
export function PasswordStrength({ password }: PasswordStrengthProps) {
const checks = {
length: password.length >= 12,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /[0-9]/.test(password),
special: /[^A-Za-z0-9]/.test(password),
}
const passedChecks = Object.values(checks).filter(Boolean).length
const strength = Math.min(100, (passedChecks / 5) * 100)
let strengthLabel = 'Weak'
let strengthColor = 'bg-red-500'
if (strength >= 80) {
strengthLabel = 'Strong'
strengthColor = 'bg-green-500'
} else if (strength >= 60) {
strengthLabel = 'Good'
strengthColor = 'bg-yellow-500'
} else if (strength >= 40) {
strengthLabel = 'Fair'
strengthColor = 'bg-orange-500'
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Password Strength</span>
<span className="text-sm font-medium">{strengthLabel}</span>
</div>
<div className="h-2 w-full rounded-full bg-gray-200">
<div
className={`h-full rounded-full transition-all ${strengthColor}`}
style={{ width: `${strength}%` }}
/>
</div>
<ul className="space-y-1 text-sm">
<li className={checks.length ? 'text-green-600' : 'text-gray-500'}>
{checks.length ? '✓' : '○'} At least 12 characters
</li>
<li className={checks.uppercase ? 'text-green-600' : 'text-gray-500'}>
{checks.uppercase ? '✓' : '○'} One uppercase letter
</li>
<li className={checks.lowercase ? 'text-green-600' : 'text-gray-500'}>
{checks.lowercase ? '✓' : '○'} One lowercase letter
</li>
<li className={checks.number ? 'text-green-600' : 'text-gray-500'}>
{checks.number ? '✓' : '○'} One number
</li>
<li className={checks.special ? 'text-green-600' : 'text-gray-500'}>
{checks.special ? '✓' : '○'} One special character
</li>
</ul>
</div>
)
}
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { passwordConfirmSchema, type PasswordConfirmData } from '@/lib/schemas/password'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { PasswordStrength } from '@/components/auth/PasswordStrength'
export default function SignupFormWithValidation() {
const [password, setPassword] = useState('')
const {
register,
handleSubmit,
formState: { errors },
} = useForm<PasswordConfirmData>({
resolver: zodResolver(passwordConfirmSchema),
})
async function onSubmit(data: PasswordConfirmData) {
// Handle signup
console.log('Signup data:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<Input
id="password"
type="password"
{...register('password')}
onChange={(e) => setPassword(e.target.value)}
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
{password && <PasswordStrength password={password} />}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium">
Confirm Password
</label>
<Input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">
{errors.confirmPassword.message}
</p>
)}
</div>
<Button type="submit" className="w-full">
Create Account
</Button>
</form>
)
}
"use server"
import { createClient } from '@/lib/supabase/server'
import { passwordSchema } from '@/lib/schemas/password'
export async function signupWithValidation(email: string, password: string) {
// Validate password on server
const validation = passwordSchema.safeParse(password)
if (!validation.success) {
return {
success: false,
error: validation.error.errors[0].message,
}
}
const supabase = await createClient()
const { data, error } = await supabase.auth.signUp({
email,
password,
})
if (error) {
return { success: false, error: error.message }
}
return { success: true, data }
}
Add to prisma/schema.prisma:
model LoginAttempt {
id String @id @default(cuid())
email String
ipAddress String @map("ip_address")
success Boolean
attemptedAt DateTime @default(now()) @map("attempted_at")
@@index([email, attemptedAt])
@@index([ipAddress, attemptedAt])
@@map("login_attempts")
}
model AccountLock {
id String @id @default(cuid())
userId String @unique @map("user_id")
lockedAt DateTime @default(now()) @map("locked_at")
unlockAt DateTime @map("unlock_at")
reason String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("account_locks")
}
Create src/lib/auth/login-attempts.ts:
import { prisma } from '@/lib/prisma'
const MAX_ATTEMPTS = 5
const LOCKOUT_DURATION_MS = 30 * 60 * 1000 // 30 minutes
const ATTEMPT_WINDOW_MS = 30 * 60 * 1000 // 30 minutes
export async function recordLoginAttempt(
email: string,
ipAddress: string,
success: boolean
): Promise<void> {
await prisma.loginAttempt.create({
data: {
email,
ipAddress,
success,
},
})
}
export async function checkAccountLocked(email: string): Promise<{
locked: boolean
unlockAt?: Date
attemptsRemaining?: number
}> {
// Check if account is explicitly locked
const user = await prisma.user.findUnique({
where: { email },
include: { accountLock: true },
})
if (!user) {
return { locked: false }
}
// Check for active lock
if (user.accountLock) {
if (user.accountLock.unlockAt > new Date()) {
return {
locked: true,
unlockAt: user.accountLock.unlockAt,
}
} else {
// Lock expired, remove it
await prisma.accountLock.delete({
where: { userId: user.id },
})
}
}
// Count failed attempts in window
const windowStart = new Date(Date.now() - ATTEMPT_WINDOW_MS)
const failedAttempts = await prisma.loginAttempt.count({
where: {
email,
success: false,
attemptedAt: {
gte: windowStart,
},
},
})
if (failedAttempts >= MAX_ATTEMPTS) {
// Lock the account
const unlockAt = new Date(Date.now() + LOCKOUT_DURATION_MS)
await prisma.accountLock.create({
data: {
userId: user.id,
unlockAt,
reason: 'Too many failed login attempts',
},
})
return {
locked: true,
unlockAt,
}
}
return {
locked: false,
attemptsRemaining: MAX_ATTEMPTS - failedAttempts,
}
}
export async function unlockAccount(userId: string): Promise<void> {
await prisma.accountLock.delete({
where: { userId },
})
// Clear failed attempts
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
})
if (user) {
await prisma.loginAttempt.deleteMany({
where: {
email: user.email,
success: false,
},
})
}
}
"use server"
import { createClient } from '@/lib/supabase/server'
import { recordLoginAttempt, checkAccountLocked } from '@/lib/auth/login-attempts'
import { headers } from 'next/headers'
export async function loginWithLockout(email: string, password: string) {
// Get IP address
const headersList = headers()
const ipAddress = headersList.get('x-forwarded-for') || 'unknown'
// Check if account is locked
const lockStatus = await checkAccountLocked(email)
if (lockStatus.locked) {
const minutesRemaining = Math.ceil(
(lockStatus.unlockAt!.getTime() - Date.now()) / 60000
)
return {
success: false,
error: `Account is locked due to too many failed login attempts. Try again in ${minutesRemaining} minutes.`,
locked: true,
}
}
// Attempt login
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
// Record attempt
await recordLoginAttempt(email, ipAddress, !error)
if (error) {
// Check if this failed attempt triggers a lock
const newLockStatus = await checkAccountLocked(email)
if (newLockStatus.locked) {
return {
success: false,
error: `Too many failed attempts. Account is now locked for 30 minutes.`,
locked: true,
}
}
return {
success: false,
error: error.message,
attemptsRemaining: newLockStatus.attemptsRemaining,
}
}
return { success: true, data }
}
'use client'
import { useState } from 'react'
import { loginWithLockout } from './actions'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useRouter } from 'next/navigation'
export default function LoginWithLockout() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setError('')
setAttemptsRemaining(null)
setLoading(true)
const result = await loginWithLockout(email, password)
if (result.success) {
router.push('/dashboard')
router.refresh()
} else {
setError(result.error)
if (result.attemptsRemaining !== undefined) {
setAttemptsRemaining(result.attemptsRemaining)
}
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8">
<div>
<h2 className="text-center text-3xl font-bold">Sign in</h2>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
{attemptsRemaining !== null && attemptsRemaining > 0 && (
<p className="mt-2 text-sm text-red-700">
{attemptsRemaining} attempt{attemptsRemaining !== 1 ? 's' : ''}{' '}
remaining before account lockout.
</p>
)}
</div>
)}
<form onSubmit={handleLogin} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</Button>
</form>
<p className="text-center text-sm text-gray-600">
<a
href="/forgot-password"
className="font-medium text-blue-600 hover:text-blue-500"
>
Forgot your password?
</a>
</p>
</div>
</div>
)
}
"use server"
import { createClient } from '@/lib/supabase/server'
import { prisma } from '@/lib/prisma'
import { unlockAccount } from '@/lib/auth/login-attempts'
export async function adminUnlockAccount(userId: string) {
// Check admin permissions
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Unauthorized' }
}
const adminUser = await prisma.user.findUnique({
where: { id: user.id },
select: { role: true },
})
if (adminUser?.role !== 'ADMIN') {
return { success: false, error: 'Insufficient permissions' }
}
// Unlock account
await unlockAccount(userId)
return { success: true }
}
references/auth-patterns.md - Comprehensive authentication patternsreferences/middleware-guide.md - Middleware configurationreferences/worldcrafter-rbac.md - Complete RBAC system with 5 rolesreferences/related-skills.md - How this skill works with other WorldCrafter skillsassets/templates/ - Auth component templatesThis skill adds authentication and authorization to existing routes, features, and Server Actions.
Protecting Existing Routes:
Complete Feature with Authentication:
Implementing Login/Logout:
Role-Based Access Control:
Database + Application Auth:
Claude will orchestrate auth-guard with other skills when:
Example orchestration:
User: "Create a blog feature where users can only edit their own posts"
Claude's workflow:
1. worldcrafter-database-setup:
- Creates BlogPost model with authorId
- Adds RLS policy: users can only update posts where authorId = auth.uid()
2. worldcrafter-feature-builder:
- Creates blog post form
- Creates CRUD Server Actions
3. worldcrafter-auth-guard (this skill):
- Adds auth check to Server Actions
- Verifies user.id matches post.authorId before updates
- Protects /posts/new route (login required)
4. worldcrafter-test-generator:
- Tests that unauthenticated users can't create posts
- Tests that users can't edit others' posts
Choose this skill when:
Choose worldcrafter-feature-builder instead when:
Use this skill AFTER other skills when:
Two-Layer Security Pattern:
# Add auth guard to existing page
python .claude/skills/worldcrafter-auth-guard/scripts/add_auth_guard.py src/app/dashboard/page.tsx
# Add auth check to Server Action
python .claude/skills/worldcrafter-auth-guard/scripts/add_auth_guard.py src/app/posts/actions.ts --action
Complete authentication implementation includes: