TL;DR
shadcn/ui gives you unstyled, accessible components built on Radix. The components are fine. What you do with them is where the risk lives. Validate form inputs on the server even when the client already validated them, keep dangerouslySetInnerHTML away from user content, and send CSRF tokens with your forms. Because you copy the code into your own project, every security decision in it is now yours to own.
Why shadcn/ui Security Matters for Vibe Coding
shadcn/ui is a popular component library for React, with one twist that changes everything about security. You don't install it as a dependency. You copy the components into your project and own them outright. So keeping them secure is on you, not on a maintainer shipping patches. And when an AI tool generates shadcn code, it tends to produce a UI that looks and works fine while quietly skipping the server-side validation or the CSRF protection.
Form Security
shadcn forms look great out of the box. The security is entirely in how you handle what they submit:
// 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 are built on Radix UI, which follows solid accessibility and security practices, so the primitives are fine. Your security still depends on how you wire them up: validate inputs, don't render user content as HTML, and check authorization before you show an action.
Do I need to update shadcn components for security patches?
Since you copied the components into your project, nobody pushes an update to you. Watch the shadcn/ui changelog and Radix UI releases yourself, and re-run the CLI to pull a fixed component when one lands. This is the tradeoff for owning the code.
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.