→ The CSS on your site is CSS you wrote — except in four places worth knowing about.
Security
Salty does its work before anyone visits your site. Your .css.ts files are evaluated in Node during the build, the result is hashed into one static stylesheet, and that file is what a browser gets. There's no style injection during render, no CSSOM insertion, no step anywhere in the pipeline that turns a string into a CSS rule while your app is running — the only styling code in your client bundle is a small mapper that picks pre-compiled class names from variant props, and it doesn't author anything.
For a lot of projects that's the entire security story: the CSS on your site is CSS you wrote, compiled before deploy, served from your own origin.
Never say never, though. Four things sit outside that guarantee — styles that arrive as data at request time, values a user hands to a prop token, stylesheets pulled from someone else's server, and the build itself, which is code running with your credentials. Three of the four come down to a single habit, so it's worth stating before the details: decide what a value is allowed to be at the point it enters your system, not at the point you render it.
What a style object can actually do
Calibration first, because CSS has a reputation as the harmless one and that reputation cuts both ways.
The scary-sounding old tricks are dead. url(javascript:…) doesn't execute in any browser you're targeting, IE's expression() is a fossil, and an SVG loaded as a background image gets no script execution. If your mental model is "CSS injection means remote code execution," you can relax slightly.
Then un-relax, because what's left isn't nothing.
- CSS makes network requests. Any
url()fetches from any host. That's a beacon: your visitor's IP and user agent handed to whoever wrote the payload, plus confirmation that a specific page was viewed. Attribute selectors turn it into a coarse read on what's actually in the DOM. - CSS owns the interface. A fixed-position rule can cover the page, hide the line of text stating how much money is about to move, or use
contenton a pseudo-element to put words on your page that you never wrote. Nothing crashes — but your users are looking at it, on your domain, and they trust both. - CSS can stop being CSS. A payload that escapes the
<style>element it was meant to live in isn't a styling problem anymore. A stray</style>in the middle of a generated rule is HTML injection, and that path ends where you'd expect it to.
So: not remote code execution, and more than nothing. Treat a style payload roughly the way you'd treat user-supplied HTML you're about to render.
Runtime styles
defineRuntime is the one API that takes a style object at request time and turns it into real CSS — from a CMS field, a database row, whatever somebody saved in a settings panel three minutes ago. It does not sanitize what you hand it. Property values pass through as text.
Not sanitizing silently is a deliberate choice rather than a gap I'm hoping you won't notice. A filter strict enough to be genuinely safe would block a good share of what this API exists for, and one loose enough to stay out of the way would mostly sell you confidence you hadn't earned. So the validation boundary is yours — which is the right place for it anyway, because you're the one who knows whether a payload came from a colleague with a CMS login or from an anonymous signup form.
What that doesn't rule out is help. Some kind of opt-in validation helper is likely to land at some point — something you call, with a shape you chose, rather than something that quietly filters your styles behind your back. Until it does, and honestly after it does too, the code below is the pattern.
How you define it
Don't hand a stored object to the parser. Write the shape you support, validate into it, and let that be the only way data becomes style:
// a plain .ts file
const HEX = /^#[0-9a-f]{6}$/i;
const RADII = ["0", "4px", "12px", "999px"];
export type ProfileStyle = { bg: string; text: string; radius: string };
export function toProfileStyle(row: Record<string, unknown>): ProfileStyle {
const hex = (v: unknown, fallback: string) =>
typeof v === "string" && HEX.test(v) ? v : fallback;
return {
bg: hex(row.bg, "#ffffff"),
text: hex(row.text, "#111111"),
radius: RADII.includes(row.radius as string) ? (row.radius as string) : "12px",
};
}Three fields, two shapes, a fallback each. Everything not on that list is not a thing a profile can be.
How you use it
const style = toProfileStyle(profile.style);
const { className, css } = await runtime.resolve({
background: style.bg,
color: style.text,
borderRadius: style.radius,
padding: "1.5rem",
border: "1px solid {colors.grey.light}",
});The person picked three values. They did not pick which properties exist — so background-image: url(…) was never on the table, and neither was anything else. Do this one thing and most of this page stops applying to you.
Four details around the edges of that API:
- The
scopeargument is a selector.runtime.css(styles, scope)writes it straight into the stylesheet, so untrusted text there escapes the rule and gets to write its own. Scopes come from your code, always. - Mind the raw-HTML sinks. The risk isn't putting CSS in a
<style>tag, it's putting it there through something that doesn't escape. React's ordinary<style>{css}</style>child isn't a raw sink; Astro's<style set:html={css} />is one by definition — and it's also the correct way to render runtime CSS there, which means the safety has to come from the payload being one you built. Same story fordangerouslySetInnerHTMLif you ever reach for it here. - Cap the size. An untrusted payload decides how much CSS you generate, not just what it says. A deeply nested object is a slow request and a fat page for every visitor who loads it. Validate length and depth alongside values.
- Know which threat model you're in. Ten editors on an internal CMS and a public profile page behind an open signup form are the same API and completely different risks.
The Runtime styles page covers the rest of that API. If you're building the profile-customization case, read this section again when it stops being theoretical.
Prop tokens
The rung below runtime styles is a prop token: {props.X} in a style, a css-x prop at the call site, one static rule and a CSS variable carrying the value. It's much narrower by construction — the value rides an inline custom property into a rule you already wrote, so a user can fill a slot but can't add a property. That's exactly why it's the right tool for a user-chosen colour, and Dynamic Values is the page for it.
Narrower isn't closed, though:
<Avatar css-bg={user.favouriteColour} />If the compiled rule is background: var(--props-bg), then a value of url(https://example.test/pixel.png) is a perfectly valid, perfectly well-escaped CSS value — and now every visitor's browser makes a request to a host somebody else picked. Nothing was injected and nothing was malformed. The value simply did what CSS values are allowed to do.
So validate the shape you actually meant, at the boundary, the same as anywhere else — a colour prop against a colour pattern, a size against a list. And when the set of looks is genuinely closed, don't take a free-form value at all: five named looks are a conditional token group and one attribute in the markup, with no user-supplied values anywhere in the picture. Theming is the better tool whenever the answer is enumerable.
The same reasoning covers the plain style prop and any custom property you let a user fill directly.
CSS from somebody else's server
defineImport("https://…") and hosted font sheets pull CSS you didn't write into your page, and imported CSS has exactly the same powers as the CSS above. Two consequences worth being deliberate about: whoever runs that host can change the file's contents at any time and your build will never notice, and every visitor's browser tells that host the visit happened — IP, user agent, referrer. If you have privacy commitments about third-party requests, a font stylesheet is one.
Self-host when you reasonably can. Vendor the file into your repo and defineImport("./vendor/thing.css"), or register fonts from local files with defineFont. When you do keep a remote sheet, keep it to hosts you'd happily name in a code review — and note that @import can't carry an integrity hash — Subresource Integrity only covers <script> and <link rel="stylesheet"> — so if SRI is part of your requirements, that sheet belongs in a <link> in your HTML rather than in a defineImport. (Google Fonts doesn't support SRI at all, for what it's worth, which is one more argument for self-hosting.)
Your build is code
.css.ts files execute during the build, in Node, on your machine or your CI runner, with whatever environment that process happens to have. Everything they import executes too. That's true of every build tool you already run, but styles feel like data and these files don't announce themselves as programs, so it's worth saying plainly: a .css.ts file is code. Keep them style-focused (it's the same advice that keeps builds fast), keep their dependency list short and reviewed, commit your lockfile, and treat a build triggered by an outside pull request with the same suspicion you'd apply anywhere else in CI.
The other direction matters just as much: everything the compiler emits is public. saltygen/index.css is served to every visitor. A signed URL inside a background image, an internal hostname, a preview API key interpolated into a value at build time — all published, in a file that's trivially readable and often cached. Reading an environment variable at build time is fine and is exactly how environment-specific config is meant to work; just keep in mind what you're reading it into.
Development builds also carry a data-component-name attribute for debugging, and production strips it. Not a vulnerability — but it is your internal component names sitting in the DOM, so don't be startled to find them in a dev screenshot.
Content Security Policy
The default is tidy, which is the nice consequence of having nothing to inject: one static stylesheet on your own origin means style-src 'self' covers ordinary Salty. No 'unsafe-inline', no nonce, nothing to special-case.
Two features need explicit accommodation:
- Prop tokens land in a
styleattribute. Inline style attributes fall understyle-src-attr(which falls back tostyle-src), and a policy that doesn't allow inline there blocks them. Server-rendered markup is where you'll meet this first, because the attribute arrives in the HTML. (To be precise: the same policy doesn't block a property set through the CSSOM afterwards —el.style.setProperty(…)isn't covered — so a strict policy can produce the confusing result of a prop token that's dead on first paint and alive after hydration.) - Runtime styles are an inline
<style>element. You render that tag yourself, so you attach the nonce yourself —<style nonce={nonce}>{css}</style>against astyle-src 'nonce-…'policy.
Worth flipping around, though, because CSP isn't only a thing to accommodate here — it's also the best mitigation available for the section above. A payload's url() can only phone home to a host your policy allows, so if you're shipping user-supplied styles at all, tighten img-src, font-src and connect-src to the origins you actually use. It's a layer rather than a fix — clever people keep finding side channels that need no network request at all — but it turns a quiet exfiltration into a blocked request and a violation report, which is a much better day.
One dev-mode caveat, because it costs people an afternoon: bundler dev servers usually inject CSS through JavaScript instead of serving the built file. A CSP violation that only shows up in development and never in a production build is usually that, not you.