TL;DR
shadcn/ui provides unstyled, accessible components built on Radix. The components themselves are secure, but how you use them matters. Always validate form inputs server-side even with client-side validation. Avoid using dangerouslySetInnerHTML with user content. Use CSRF tokens with forms. The copy-paste model means you own the code and any security customizations.
Why shadcn/ui Security Matters for Vibe Coding
shadcn/ui is a popular component library for React. Unlike traditional libraries, you copy components into your project and own them. This means you're responsible for keeping them secure. When AI tools generate shadcn code, they often create working UIs but may miss proper validation or CSRF protection.
Form Security
shadcn forms look great, but security comes from proper handling:
// Secure form with react-hook-form and Zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Form, FormField, FormItem, FormLabel, FormControl } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
const formSchema = z.object({
email: z.string().email('Invalid email').max(254),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export function LoginForm() {
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: { email: '', password: '' },
});
async function onSubmit(values: z.infer<typeof formSchema>) {
// Client-side validation passed, but ALWAYS validate server-side too
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
credentials: 'include', // Include cookies for CSRF
});
if (!response.ok) {
const error = await response.json();
form.setError('root', { message: error.message });
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" autoComplete="email" {...field} />
</FormControl>
</FormItem>
)}
/>
{/* Password field... */}
<Button type="submit">Log in</Button>
</form>
</Form>
);
}
XSS Prevention
// DANGEROUS: Rendering user content as HTML
function Comment({ content }: { content: string }) {
return (
<Card>
<CardContent dangerouslySetInnerHTML={{ __html: content }} />
</Card>
);
}
// SAFE: React escapes by default
function Comment({ content }: { content: string }) {
return (
<Card>
<CardContent>{content}</CardContent>
</Card>
);
}
// SAFE: If you need some formatting, use a sanitizer
import DOMPurify from 'dompurify';
function RichComment({ content }: { content: string }) {
const sanitized = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href'],
});
return (
<Card>
<CardContent dangerouslySetInnerHTML={{ __html: sanitized }} />
</Card>
);
}
Dialog and Modal Security
// Dialogs for sensitive actions should require confirmation
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
function DeleteAccountButton() {
const [isDeleting, setIsDeleting] = useState(false);
async function handleDelete() {
setIsDeleting(true);
try {
const response = await fetch('/api/account', {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to delete');
// Redirect to goodbye page
window.location.href = '/goodbye';
} catch (error) {
setIsDeleting(false);
toast.error('Failed to delete account');
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
account and remove all your data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground"
>
{isDeleting ? 'Deleting...' : 'Delete Account'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
Command/Search Component Security
// Be careful with dynamic command items
import { Command, CommandInput, CommandList, CommandItem } from '@/components/ui/command';
function SearchCommand({ items }: { items: SearchResult[] }) {
return (
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
{items.map((item) => (
<CommandItem
key={item.id}
value={item.title} // Safe: Used for filtering only
onSelect={() => {
// Validate the action before executing
if (isValidAction(item.action)) {
executeAction(item.action);
}
}}
>
{/* Safe: React escapes this */}
{item.title}
</CommandItem>
))}
</CommandList>
</Command>
);
}
// If items come from user input or external API, validate them
function isValidAction(action: string): boolean {
const allowedActions = ['navigate', 'search', 'filter'];
const [actionType] = action.split(':');
return allowedActions.includes(actionType);
}
Table Data Security
// When displaying user data in tables
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
function UsersTable({ users }: { users: User[] }) {
return (
<Table>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
{/* Safe: React escapes text content */}
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
{/* DANGEROUS: Don't do this */}
<TableCell dangerouslySetInnerHTML={{ __html: user.bio }} />
{/* Safe: Use text content */}
<TableCell>{user.bio}</TableCell>
{/* Safe: Actions with proper authorization */}
<TableCell>
{canEditUser(user.id) && (
<Button onClick={() => editUser(user.id)}>Edit</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
shadcn/ui Security Checklist
- Form validation with Zod (both client and server)
- CSRF tokens included with form submissions
- No dangerouslySetInnerHTML with user content
- DOMPurify used when HTML rendering is required
- Destructive actions require confirmation dialogs
- Command/action handlers validate allowed actions
- Table data displayed as text, not HTML
- Authorization checked before showing action buttons
Is shadcn/ui secure out of the box?
The components themselves are built on Radix UI which follows accessibility and security best practices. However, security depends on how you use them. Always validate inputs, avoid rendering user content as HTML, and implement proper authorization.
Do I need to update shadcn components for security patches?
Since you copy components into your project, you're responsible for updates. Watch the shadcn/ui changelog and Radix UI releases for security-related updates. You can re-run the CLI to update components.
How do I handle authentication state in shadcn components?
shadcn components are purely presentational. Use your auth library (NextAuth, Clerk, etc.) to manage state, and conditionally render components or buttons based on authentication and authorization status.
Scan Your shadcn/ui Components
Find XSS vulnerabilities, missing validation, and unsafe patterns before they reach production.
Start Free Scan