supabase
Handles the full Supabase workflow from schema changes to deployment, with built-in security guardrails that catch common traps like RLS...
worldcrafter-auth-guard
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 protecte...
Full skill instructions
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:
Handles the full Supabase workflow from schema changes to deployment, with built-in security guardrails that catch common traps like RLS...
Comprehensive guides and best practices for Neon Serverless Postgres, covering setup, connection methods, authentication, and platform APIs.
Guide for setting up and using Firebase Authentication. Use this skill when the user's app requires user sign-in, user management, or secure data access using auth rules.
A skill to evaluate how secure Firestore security rules are. Use this when Firestore security rules are updated to ensure that the generated rules are extremely secure and robust.
Official skill for integrating Firebase AI Logic (Gemini API) into web applications. Covers setup, multimodal inference, structured output, and security.
Complete Better Auth server and client setup with database adapters, session management, plugins, and security configuration.
Deploy and manage projects on Vercel using token-based authentication. Use when working with Vercel CLI using access tokens rather than interactive login — e.g. "deploy to vercel", "set up vercel", "add environment variables to vercel".
Complete Cloudflare platform integration with decision trees for compute, storage, AI, networking, security, and infrastructure-as-code.
Run Azure compliance and security audits with azqr plus Key Vault expiration checks. Covers best-practice assessment, resource review, policy/compliance validation, and security posture checks. WHEN: compliance scan, security audit, BEFORE running azqr (compliance cli tool), Azure best practices, Key Vault expiration check, expired certificates, expiring secrets, orphaned resources, compliance assessment.
Send, read, and manage Gmail messages, drafts, labels, and account settings.
Comprehensive website auditing across 230+ rules in 21 categories including SEO, performance, security, and accessibility.
Shared authentication, CLI syntax, and output formatting patterns for gws Google Workspace commands.
Discover your celebrity doppelgänger with StarByFace!
GeoSpy: Pricing, Features, FAQs, and Alternatives for AI Teams
Detect AI-generated voices to protect against audio fraud.
Ensure Your Content's Originality with AI Plagiarism Checker
Upscale images by 400% without quality loss
Protect your social media from copyright disputes
Accurately Detect AI-Generated Content with TheChecker.AI
Chrome extension that detects and flags AI-generated content