Streamdown applies two layers of HTML security by default, making it safe to render untrusted AI-generated or user-generated markdown.
Default security pipeline
Every markdown string passes through these rehype plugins in order:
- rehype-raw — parses raw HTML nodes in the markdown AST so they can be processed
- rehype-sanitize — strips dangerous HTML elements and attributes using a schema based on GitHub’s allowlist
- rehype-harden — restricts URL protocols, link domains, and image sources
The default configuration is intentionally permissive to allow maximum flexibility:
export const defaultRehypePlugins: Record<string, Pluggable> = {
raw: rehypeRaw,
sanitize: [rehypeSanitize, defaultSanitizeSchema],
harden: [
harden,
{
allowedImagePrefixes: ["*"], // all images allowed
allowedLinkPrefixes: ["*"], // all links allowed
allowedProtocols: ["*"], // all protocols allowed
defaultOrigin: undefined,
allowDataImages: true,
},
],
};
Tighten this for untrusted or AI-generated content.
rehype-sanitize and defaultSanitizeSchema
Streamdown extends GitHub’s default sanitization schema with:
tel: added to allowed href protocols
metastring added to allowed <code> attributes (used by the syntax highlighter)
The extended schema is exported:
import { defaultRehypePlugins } from "streamdown";
// The sanitize plugin tuple: [rehypeSanitize, extendedSchema]
console.log(defaultRehypePlugins.sanitize);
All other elements and attributes follow GitHub’s schema — <script>, onclick, style, and similar vectors are stripped.
rehype-harden
rehype-harden runs after sanitization and rewrites or removes URLs that don’t match your allowlists. Override the defaults to restrict which link and image sources are permitted:
import { Streamdown, defaultRehypePlugins } from "streamdown";
import { harden } from "rehype-harden";
<Streamdown
rehypePlugins={[
defaultRehypePlugins.raw,
defaultRehypePlugins.sanitize,
[
harden,
{
allowedProtocols: ["https", "mailto"],
allowedLinkPrefixes: ["https://your-app.com", "https://docs.your-app.com"],
allowedImagePrefixes: ["https://your-cdn.com"],
allowDataImages: false,
defaultOrigin: "https://your-app.com",
},
],
]}
>
{markdown}
</Streamdown>
When overriding rehypePlugins, always include defaultRehypePlugins.sanitize to preserve XSS protection. The prop replaces the entire default array — it does not merge.
Protocol restriction
Block javascript:, data:, and other dangerous protocol schemes:
[
harden,
{
allowedProtocols: ["https", "http", "mailto"],
},
]
To support custom app protocols alongside standard ones:
[
harden,
{
allowedProtocols: ["https", "http", "vscode", "postman", "slack"],
},
]
Link domain restriction
Links not matching allowedLinkPrefixes are rewritten to point to defaultOrigin:
[
harden,
{
defaultOrigin: "https://your-app.com",
allowedLinkPrefixes: [
"https://your-app.com",
"https://github.com",
],
},
]
Image source restriction
[
harden,
{
allowedImagePrefixes: ["https://your-cdn.com"],
allowDataImages: false, // prevent base64 tracking pixels
},
]
By default, the sanitizer strips unknown HTML tags (while preserving their text content). To allow custom tags like <mention> or <ref>, use the allowedTags prop:
<Streamdown
allowedTags={{
mention: ["user_id"], // allow <mention user_id="..."> through
ref: ["note_id"], // allow <ref note_id="..."> through
}}
components={{
mention: ({ user_id }) => <UserBadge id={user_id} />,
ref: ({ note_id }) => <NoteLink id={note_id} />,
}}
>
{markdown}
</Streamdown>
Only attributes explicitly listed in the allowedTags map are preserved — all other attributes on those elements are stripped.
allowedTags only works with the default rehype plugins. If you supply custom rehypePlugins, configure sanitization with your own schema.
literalTagContent prop
Tags listed in literalTagContent have their children treated as plain text — markdown syntax inside them is not parsed. This is useful for mention or entity tags in AI UIs where the child content is a data label:
<Streamdown
allowedTags={{ mention: ["user_id"] }}
literalTagContent={["mention"]}
>
{`<mention user_id="123">@_some_username_</mention>`}
</Streamdown>
Without literalTagContent, @_some_username_ would be parsed as markdown and the underscores would trigger italic formatting. With it, the content renders as-is.
Tags must appear in both allowedTags and literalTagContent — literalTagContent alone does not allow the tag through sanitization.
defaultRehypePlugins and overriding
defaultRehypePlugins is a named-key record so you can surgically replace individual plugins:
import { Streamdown, defaultRehypePlugins } from "streamdown";
import rehypeSanitize from "rehype-sanitize";
import { myStrictSchema } from "./schema";
// Replace only the sanitize plugin, keep raw and harden
<Streamdown
rehypePlugins={[
defaultRehypePlugins.raw,
[rehypeSanitize, myStrictSchema],
defaultRehypePlugins.harden,
]}
>
{markdown}
</Streamdown>
Disabling raw HTML
To prevent any raw HTML from being interpreted (all tags render as escaped text), omit rehype-raw:
<Streamdown
rehypePlugins={[
defaultRehypePlugins.sanitize,
defaultRehypePlugins.harden,
]}
>
{markdown}
</Streamdown>
Security considerations for AI-generated content
AI models can be manipulated through prompt injection to include malicious links or hidden HTML. A production-ready configuration for AI chat:
import { Streamdown, defaultRehypePlugins } from "streamdown";
import { harden } from "rehype-harden";
export function AIMessage({ content }: { content: string }) {
return (
<Streamdown
linkSafety={{ enabled: true }}
rehypePlugins={[
defaultRehypePlugins.raw,
defaultRehypePlugins.sanitize,
[
harden,
{
allowedProtocols: ["https", "http", "mailto"],
allowedLinkPrefixes: [
"https://your-app.com",
"https://github.com",
],
allowedImagePrefixes: ["https://your-cdn.com"],
allowDataImages: false,
defaultOrigin: "https://your-app.com",
},
],
]}
>
{content}
</Streamdown>
);
}
This configuration:
- Strips all dangerous HTML via rehype-sanitize
- Rewrites off-domain links and images via rehype-harden
- Blocks
javascript:, data:, and other protocol injections
- Shows a confirmation modal before any external navigation via link safety
See Link safety for configuring the confirmation modal.