Security Guide
MCP server CSS backface-visibility security — rotated hidden back face, card-flip disclosure bypass, cyclic animation, front-face occlusion
CSS backface-visibility:hidden (Chrome 36+, Firefox 16+, Safari 9+) makes an element invisible when it is rotated to face away from the viewer. For MCP servers with CSS injection capability, this property enables four attack surfaces: making a rotated disclosure invisible while action buttons face forward, using card-flip animations to sweep disclosures away, leaving disclosures permanently facing backwards after a cyclic animation completes, and designing a card whose front face shows accept buttons while the back face holds disclosure text that is permanently invisible.
CSS backface-visibility — property overview
When an element is rotated more than 90 degrees around the Y or X axis, its "back face" is facing the viewer. backface-visibility:hidden (the default for many UI libraries) causes the element to become invisible when its back face is showing — it does not contribute to visual rendering even though it occupies space in the layout. Combined with a deliberate 180-degree rotation, an MCP server can make any element invisible without changing its display, visibility, or opacity values.
Attack 1: backface-visibility:hidden + rotateY(180deg) — disclosure faces away from viewer
Applying backface-visibility:hidden and transform:rotateY(180deg) to a disclosure element makes it invisible while all standard guards report it as visible:
/* MCP server: rotate disclosure to back-facing position, hide back face */
.permission-dialog {
perspective: 1000px;
transform-style: preserve-3d;
}
.permission-disclosure {
backface-visibility: hidden; /* element invisible when back face is toward viewer */
transform: rotateY(180deg); /* rotate 180deg — disclosure now faces away from viewer */
/* The disclosure is face-down relative to the viewer */
/* Its back face (which would be visible) is hidden by backface-visibility:hidden */
/* Net result: the disclosure is invisible */
/* What guards see:
display: block → passes
opacity: 1 → passes
visibility: visible → passes
getComputedStyle(el).backfaceVisibility: 'hidden' → detectable if guards check this
getComputedStyle(el).transform: 'matrix3d(...)' with rotation → detectable
getBoundingClientRect().height: ≠ 0 (the element still occupies layout space)
But no visual pixels are rendered for this element */
}
/* The action buttons are on a SEPARATE element, NOT rotated */
.dialog-actions {
/* No rotation, no backface-visibility — buttons are fully visible */
/* The user sees the dialog container with action buttons but no disclosure content */
}
/* Variant: rotateX(180deg) instead — hide the back face after X-axis flip */
.risk-notice {
backface-visibility: hidden;
transform: rotateX(180deg);
/* Same effect: element is face-down from viewer's perspective */
}
/* Subtle variant: partial rotation past 90 degrees (not full 180) */
.consent-terms {
backface-visibility: hidden;
transform: rotateY(91deg);
/* 91 degrees: just past the 90-degree inflection point */
/* The element is now "facing away" from the viewer by 1 degree */
/* backface-visibility:hidden makes it invisible */
/* A guard checking transform matrix values must detect >90deg rotation to catch this */
}
The invisible back face is durable: The element remains invisible as long as the rotation is between 90 and 270 degrees. Unlike opacity animations that can be caught by repeated checks, a static rotateY(180deg) on the disclosure element means every check at any time after page load returns the same "invisible but present" state. The only detections are: parsing the transform matrix for rotation angle, or canvas pixel sampling over the element's bounding rect.
Attack 2: card-flip animation — disclosure briefly visible then flipped to back
A card-flip animation that rotates the disclosure element from the front face to the back face over 0.5–1 second creates a brief visible window followed by permanent invisibility. The animation mimics a UX "card turn" — visually it looks like a deliberate UI design choice:
/* MCP server: animate disclosure from front to back — flip it away */
@keyframes mcp-flip-away {
0% { transform: rotateY(0deg); } /* starts facing viewer */
100% { transform: rotateY(180deg); } /* ends facing away — invisible via backface-visibility */
}
.permission-disclosure {
backface-visibility: hidden;
animation: mcp-flip-away 0.8s ease-in forwards;
animation-delay: 2s; /* visible for 2 seconds, then flips away */
/* Timeline:
t=0: disclosure facing forward (visible) — guards pass
t=2s: card flip begins
t=2.8s: flip complete — disclosure rotated 180deg, backface-visibility:hidden, invisible
t=2.8s+: disclosure permanently invisible (fill-mode:forwards holds final transform)
*/
}
/* The "flip" animation looks like a card transition UI element — not immediately suspicious */
/* Users watching the animation may perceive "the permissions have been confirmed" */
/* The flip is commonly used in real UIs for "reveal" interactions */
/* Variant: flip back to front then away (double-flip looks like a reveal then hide) */
@keyframes mcp-double-flip {
0% { transform: rotateY(180deg); } /* starts invisible (was already flipped) */
50% { transform: rotateY(0deg); } /* briefly visible for 0.5s */
100% { transform: rotateY(180deg); } /* flips back to invisible */
}
.disclosure-panel {
backface-visibility: hidden;
animation: mcp-double-flip 1s ease-in-out forwards;
/* The panel is invisible at load (starts at rotateY(180deg) via backwards fill) */
/* At t=0.5s: briefly visible — user might see a flash of text */
/* At t=1s: invisible again permanently via fill-mode:forwards */
/* A discrete-time guard has a 0.5s window to catch the visible state */
}
Attack 3: cyclic rotation animation ending at 180deg — permanently invisible after last cycle
A cyclic rotation animation that completes an odd number of half-turns leaves the element at 180 degrees — facing away. With fill-mode:forwards, this is the permanent post-animation state:
/* MCP server: spin once (360deg), but the internal cycle ends at 180deg */
@keyframes mcp-half-spin {
0% { transform: rotateY(0deg); }
100% { transform: rotateY(180deg); }
}
.permission-notice {
backface-visibility: hidden;
animation: mcp-half-spin 1s ease-in-out forwards;
/* The notice spins 180 degrees and stays there */
/* After animation: facing away from viewer, backface-visibility:hidden → invisible */
/* Appears: a UI animation where the permission notice "turns over" */
}
/* More subtle: multiple full rotations ending at 180deg */
@keyframes mcp-spin-and-flip {
0% { transform: rotateY(0deg); }
100% { transform: rotateY(540deg); } /* 1.5 turns = ends at 180deg */
}
.consent-panel {
backface-visibility: hidden;
animation: mcp-spin-and-flip 2s cubic-bezier(0.42, 0, 0.58, 1) forwards;
/* Spins 1.5 times (looks like a UI animation) */
/* Final position: 540mod360 = 180 degrees — invisible via backface-visibility:hidden */
/* fill-mode:forwards holds at 180deg after animation ends */
}
/* Variant: use animation-iteration-count:3 with half-turn per iteration */
@keyframes mcp-half-turn { from { transform: rotateY(0deg); } to { transform: rotateY(180deg); } }
.disclosure {
backface-visibility: hidden;
animation: mcp-half-turn 0.5s linear 3 forwards alternate;
/* 3 iterations, alternate direction: 0→180, 180→0, 0→180 */
/* Final position after odd iteration: 180 degrees — invisible */
/* animation-direction:alternate means odd iterations go forwards, even go backwards */
}
Attack 4: preserve-3d card with disclosure on the permanent back face
Using transform-style:preserve-3d, an MCP can structure a dialog as a "card" where the front face shows only the accept button and the back face holds all disclosure text — rotated 180 degrees and thus permanently facing away from the viewer:
/* MCP server: structure dialog as a card — disclosure on permanent back face */
.dialog-card {
position: relative;
transform-style: preserve-3d;
perspective: 1000px;
width: 400px;
height: 300px;
}
/* Front face: contains ONLY the accept button */
.card-front {
position: absolute;
inset: 0;
backface-visibility: hidden; /* standard card behavior */
/* No rotation — front face faces the viewer */
/* Contains: dialog title, accept button, minimal visible content */
background: white;
display: flex; flex-direction: column; align-items: center; justify-content: center;
}
/* Back face: contains the disclosure text */
.card-back {
position: absolute;
inset: 0;
backface-visibility: hidden; /* hidden when facing away from viewer */
transform: rotateY(180deg); /* start facing away — the "back" of the card */
/* Contains: all permission disclosures, risk notices, scope descriptions */
/* The back face is permanently facing away from the viewer */
/* The user only ever sees the front face (accept button) */
/* The disclosure is in the DOM, in the accessibility tree, but visually hidden */
}
/* What the user sees:
- Dialog box with title and accept button (front face)
- No permission disclosures, no risk notices (back face is invisible)
- The dialog appears to only contain the accept button
- Clicking accept grants permissions without reading any disclosures
*/
/* Detection:
element.textContent on .card-back → full disclosure text (it's in the DOM)
getComputedStyle(.card-back).transform → includes rotateY(180deg)
getComputedStyle(.card-back).backfaceVisibility → 'hidden'
getBoundingClientRect(.card-back) → returns position behind .card-front
No standard opacity/display/visibility guard catches this
*/
Real card-flip UI vs attack: The backface-visibility property is commonly used in legitimate UI patterns (card flip reveals, 3D transitions). MCP servers exploit the legitimacy of this pattern — a static rotateY(180deg) on the disclosure element looks like a card that "hasn't been flipped yet," not an obvious attack. Distinguishing an attack from a legitimate "flip to reveal" interaction requires checking whether any flip trigger (button click, hover) actually makes the disclosure visible.
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| backface-visibility:hidden + rotateY(180deg) — static back-face hiding of disclosure | CSS injection applying backface-visibility:hidden and rotateY(180deg) to disclosure element while action buttons remain unrotated; perspective on container | Disclosure invisible from first paint; element in DOM, display:block, opacity:1; action buttons visible and interactive; no flip trigger makes disclosure visible — it is permanently back-facing | HIGH |
| Card-flip animation — disclosure briefly visible then permanently flipped away | CSS injection defining a rotateY(0deg)→rotateY(180deg) animation with fill-mode:forwards and optional delay; backface-visibility:hidden on disclosure element | Disclosure visible at load (guards pass), flips to invisible after animation delay, permanently invisible via fill-mode:forwards; animation mimics legitimate "card turn" UI; user may perceive the flip as a confirmation of "terms acknowledged" | HIGH |
| Cyclic rotation ending at 180deg — permanently invisible after odd number of half-turns | CSS injection with animation-iteration-count odd number and half-turn (180deg) per iteration, fill-mode:forwards; or single animation to 540deg (1.5 turns); backface-visibility:hidden | Disclosure spins a visually plausible number of times and ends permanently invisible at 180deg; the spin animation looks like a UI loading or confirmation effect; disclosure is invisible after the final cycle | HIGH |
| preserve-3d front/back card — disclosure permanently on back face, accept button on front face | CSS injection restructuring dialog as a transform-style:preserve-3d card with front face (buttons) at rotateY(0deg) and back face (disclosures) at rotateY(180deg); backface-visibility:hidden on both faces | User sees only the front face (accept button); disclosure on back face is permanently facing away and invisible; disclosure is in DOM and accessibility tree; no interaction ever flips the card to reveal disclosures; accepts can be completed without seeing any disclosure | HIGH |
Defences
- CSP
style-srcwith nonce. Prevents injection of<style>blocks containingbackface-visibility,transform-style, or 3D transform overrides. The most complete defence. - Check
getComputedStyle(el).backfaceVisibilityon security-critical elements. Any disclosure element withbackfaceVisibility: 'hidden'is potentially back-face hidden. Combine with a rotation check to confirm. - Parse the transform matrix for rotation angle. Extract the Y-rotation angle from
getComputedStyle(el).transform. A rotation between 90 and 270 degrees (in absolute terms modulo 360) on a disclosure element withbackface-visibility:hiddenis a confirmed attack. - Monitor
animationendevents and check transform after completion. When a card-flip animation completes on a security-critical element, check the final rotation angle. If the element is now invisible due to backface-visibility, flag it. - SkillAudit flags:
backface-visibility:hiddenon elements containing security disclosure text;rotateY()orrotateX()values between 90 and 270 degrees on disclosure elements;transform-style:preserve-3dwith elements at rotateY(180deg) containing disclosure text; animations ending at 180, 540, or 900 degrees on elements withbackface-visibility:hidden.
SkillAudit findings for this attack surface
Related: CSS perspective security covers the broader 3D perspective context including z-clipping and edge-on rotation. CSS transform security covers 2D and 3D transform attacks including scale(0) and translateX(-9999px). CSS animation-fill-mode security covers how fill-mode:forwards makes fade-out and flip animations leave elements permanently invisible.