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.

AttackPrerequisiteWhat it enablesSeverity
backface-visibility:hidden + rotateY(180deg) — static back-face hiding of disclosureCSS injection applying backface-visibility:hidden and rotateY(180deg) to disclosure element while action buttons remain unrotated; perspective on containerDisclosure 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-facingHIGH
Card-flip animation — disclosure briefly visible then permanently flipped awayCSS injection defining a rotateY(0deg)→rotateY(180deg) animation with fill-mode:forwards and optional delay; backface-visibility:hidden on disclosure elementDisclosure 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-turnsCSS 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:hiddenDisclosure 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 cycleHIGH
preserve-3d front/back card — disclosure permanently on back face, accept button on front faceCSS 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 facesUser 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 disclosureHIGH

Defences

SkillAudit findings for this attack surface

HIGHbackface-visibility:hidden + rotateY(180deg) — static back-face hiding of permission disclosure: MCP server injects backface-visibility:hidden and rotateY(180deg) on the permission disclosure element within a preserve-3d dialog container; disclosure is permanently face-down from viewer — invisible from first paint; display:block, opacity:1, visibility:visible; action buttons on a separate unrotated element are fully visible and interactive
HIGHCard-flip animation — disclosure briefly visible then permanently flipped to back-facing invisible state: MCP server injects a 0→180deg rotateY animation with fill-mode:forwards and optional delay; disclosure is visible at load (guards pass), flips away after delay; animation mimics legitimate card-flip UI; user may interpret the flip as acknowledgment rather than as disclosure concealment; disclosure permanently invisible after animation completes
HIGHCyclic rotation ending at 180deg — disclosure permanently invisible after animation completes at odd half-turn: MCP server injects an animation spinning 540 degrees (1.5 turns) with fill-mode:forwards; ends at 180deg rotation — disclosure permanently back-facing; backface-visibility:hidden makes it invisible; spin looks like a UI loading or confirmation animation; after completion no running animation is detectable
HIGHpreserve-3d front/back card structure — disclosure on permanent back face, accept button on front face only: MCP server restructures dialog as a preserve-3d card with accept button on the front face (rotateY:0deg) and all disclosure content on the back face (rotateY:180deg) with backface-visibility:hidden; user sees only accept button; no interaction flips the card to reveal disclosures; accepts granted without user reading any permissions

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.

← Blog  |  Security Checklist