> ## Documentation Index
> Fetch the complete documentation index at: https://docs.corbado.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Passkey Creation: After Login of MFA Account (post-login nudge)

> Web flow for offering passkey creation after the user completes the account's MFA sign-in policy.

export const ToolingFrame = ({imageURL, caption, imageName, containerBackground = 'rgb(249,250,252,1)'}) => {
  React.useEffect(() => {
    const style = document.createElement('style');
    style.textContent = `
            .drag-hint {
                bottom: 20px;
            }
            .caption-text {
                font-size: 16px;
            }
            @media (max-width: 768px) {
                .drag-hint {
                    bottom: 80px !important;
                }
                .caption-text {
                    font-size: 12px !important;
                }
                .caption-container {
                    padding: 6px !important;
                }
            }
        `;
    document.head.appendChild(style);
    return () => document.head.removeChild(style);
  }, []);
  const [showLightbox, setShowLightbox] = React.useState(false);
  const [viewportPadding, setViewportPadding] = React.useState(100);
  const imgRef = React.useRef(null);
  const containerRef = React.useRef(null);
  const imageTransformRef = React.useRef(null);
  const scaleDisplayRef = React.useRef(null);
  const dragHintRef = React.useRef(null);
  const zoomInBtnRef = React.useRef(null);
  const zoomOutBtnRef = React.useRef(null);
  const resetBtnRef = React.useRef(null);
  const stateRef = React.useRef({
    isDragging: false,
    isZooming: false,
    position: {
      x: 0,
      y: 0
    },
    dragStart: {
      x: 0,
      y: 0
    },
    initialDistance: 0,
    imageDimensions: {
      width: 0,
      height: 0
    },
    imageLoaded: false,
    naturalDimensions: {
      width: 0,
      height: 0
    },
    initialDimensions: {
      width: 0,
      height: 0
    },
    gestureBaseWidth: 0
  });
  const maxScale = 3;
  const zoomStep = 0.2;
  const getViewportPadding = React.useCallback(() => {
    const width = typeof window !== 'undefined' ? window.innerWidth : 1200;
    if (width < 640) return 20;
    if (width < 1024) return 40;
    return 100;
  }, []);
  React.useEffect(() => {
    const updatePadding = () => setViewportPadding(getViewportPadding());
    updatePadding();
    window.addEventListener('resize', updatePadding);
    return () => window.removeEventListener('resize', updatePadding);
  }, [getViewportPadding]);
  const updateTransform = () => {
    if (imageTransformRef.current) {
      const {position, imageDimensions} = stateRef.current;
      imageTransformRef.current.style.transform = `translate(${position.x}px, ${position.y}px)`;
      const img = imageTransformRef.current.querySelector('img');
      if (img && imageDimensions.width) {
        img.style.width = `${imageDimensions.width}px`;
        img.style.height = 'auto';
        void img.offsetHeight;
        const actualHeight = img.offsetHeight;
        if (actualHeight && actualHeight !== imageDimensions.height) {
          stateRef.current.imageDimensions.height = actualHeight;
        }
      }
    }
    if (scaleDisplayRef.current) {
      const {imageDimensions, initialDimensions} = stateRef.current;
      if (initialDimensions.width) {
        const relativeScale = imageDimensions.width / initialDimensions.width * 100;
        scaleDisplayRef.current.textContent = `${Math.round(relativeScale)}%`;
      }
    }
    updateButtonStates();
    updateDragHint();
  };
  const updateButtonStates = () => {
    const {imageDimensions, position, initialDimensions} = stateRef.current;
    if (zoomInBtnRef.current && initialDimensions.width) {
      const absoluteMaxWidth = initialDimensions.width * maxScale;
      const isDisabled = imageDimensions.width >= absoluteMaxWidth;
      zoomInBtnRef.current.style.opacity = isDisabled ? '0.5' : '1';
      zoomInBtnRef.current.style.cursor = isDisabled ? 'not-allowed' : 'pointer';
    }
    if (zoomOutBtnRef.current && initialDimensions.width) {
      const isDisabled = imageDimensions.width <= initialDimensions.width;
      zoomOutBtnRef.current.style.opacity = isDisabled ? '0.5' : '1';
      zoomOutBtnRef.current.style.cursor = isDisabled ? 'not-allowed' : 'pointer';
    }
    if (resetBtnRef.current && initialDimensions.width) {
      const isDisabled = imageDimensions.width === initialDimensions.width && position.x === 0 && position.y === 0;
      resetBtnRef.current.style.opacity = isDisabled ? '0.5' : '1';
      resetBtnRef.current.style.cursor = isDisabled ? 'not-allowed' : 'pointer';
    }
  };
  const updateDragHint = () => {
    if (dragHintRef.current && containerRef.current) {
      const {imageDimensions} = stateRef.current;
      const imgWidth = imageDimensions.width;
      const imgHeight = imageDimensions.height;
      const containerWidth = containerRef.current.offsetWidth;
      const containerHeight = containerRef.current.offsetHeight;
      const shouldShow = imgWidth > containerWidth || imgHeight > containerHeight;
      dragHintRef.current.style.display = shouldShow ? 'block' : 'none';
    }
  };
  const updateContainerCursor = () => {
    if (!containerRef.current) return;
    const {isDragging, imageLoaded, imageDimensions} = stateRef.current;
    if (!imageLoaded) {
      containerRef.current.style.cursor = 'wait';
      return;
    }
    if (isDragging) {
      containerRef.current.style.cursor = 'grabbing';
      return;
    }
    const imgWidth = imageDimensions.width;
    const imgHeight = imageDimensions.height;
    const containerWidth = containerRef.current.offsetWidth;
    const containerHeight = containerRef.current.offsetHeight;
    const isOverflowing = imgWidth > containerWidth || imgHeight > containerHeight;
    containerRef.current.style.cursor = isOverflowing ? 'grab' : 'default';
  };
  const handleDownload = async e => {
    e.preventDefault();
    e.stopPropagation();
    try {
      const response = await fetch(imageURL);
      const blob = await response.blob();
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.download = imageName || 'image.webp';
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url);
    } catch (error) {
      console.error('Download failed:', error);
    }
  };
  const handleImageClick = e => {
    e.preventDefault();
    e.stopPropagation();
    setShowLightbox(true);
  };
  const closeLightbox = () => {
    setShowLightbox(false);
    stateRef.current = {
      isDragging: false,
      isZooming: false,
      position: {
        x: 0,
        y: 0
      },
      dragStart: {
        x: 0,
        y: 0
      },
      initialDistance: 0,
      imageDimensions: {
        width: 0,
        height: 0
      },
      imageLoaded: false,
      naturalDimensions: {
        width: 0,
        height: 0
      },
      initialDimensions: {
        width: 0,
        height: 0
      },
      gestureBaseWidth: 0
    };
  };
  const handleMouseDown = e => {
    const state = stateRef.current;
    if (!containerRef.current || !state.imageDimensions.width || !state.imageDimensions.height) return;
    const containerWidth = containerRef.current.offsetWidth;
    const containerHeight = containerRef.current.offsetHeight;
    const imgWidth = state.imageDimensions.width;
    const imgHeight = state.imageDimensions.height;
    const isOverflowing = imgWidth - containerWidth >= 1 || imgHeight - containerHeight >= 1;
    if (!isOverflowing) return;
    state.isDragging = true;
    state.dragStart = {
      x: e.clientX - state.position.x,
      y: e.clientY - state.position.y
    };
    updateContainerCursor();
  };
  const handleMouseMove = e => {
    const state = stateRef.current;
    if (!state.isDragging || !containerRef.current) return;
    let newX = e.clientX - state.dragStart.x;
    let newY = e.clientY - state.dragStart.y;
    if (state.imageDimensions.width && state.imageDimensions.height) {
      const containerWidth = containerRef.current.offsetWidth;
      const containerHeight = containerRef.current.offsetHeight;
      const imgWidth = state.imageDimensions.width;
      const imgHeight = state.imageDimensions.height;
      const isWider = imgWidth - containerWidth >= 1;
      const isTaller = imgHeight - containerHeight >= 1;
      if (isWider) {
        const halfOverflowX = (imgWidth - containerWidth) / 2;
        newX = Math.min(Math.max(newX, -halfOverflowX), halfOverflowX);
      } else {
        newX = 0;
      }
      if (isTaller) {
        const halfOverflowY = (imgHeight - containerHeight) / 2;
        newY = Math.min(Math.max(newY, -halfOverflowY), halfOverflowY);
      } else {
        newY = 0;
      }
    }
    state.position = {
      x: newX,
      y: newY
    };
    updateTransform();
  };
  const handleMouseUp = () => {
    stateRef.current.isDragging = false;
    updateContainerCursor();
  };
  const handleZoom = (newWidth, centerX, centerY) => {
    const state = stateRef.current;
    if (!containerRef.current || !state.imageDimensions.width || !state.imageDimensions.height) return;
    if (!state.initialDimensions.width || !state.initialDimensions.height) return;
    if (!state.naturalDimensions.width || !state.naturalDimensions.height) return;
    const absoluteMaxWidth = state.initialDimensions.width * maxScale;
    const boundedWidth = Math.max(state.initialDimensions.width, Math.min(absoluteMaxWidth, newWidth));
    const aspectRatio = state.naturalDimensions.height / state.naturalDimensions.width;
    const boundedHeight = boundedWidth * aspectRatio;
    let newX = state.position.x;
    let newY = state.position.y;
    if (centerX !== undefined && centerY !== undefined) {
      const rect = containerRef.current.getBoundingClientRect();
      const offsetX = centerX - rect.left - rect.width / 2;
      const offsetY = centerY - rect.top - rect.height / 2;
      const sizeRatio = boundedWidth / state.imageDimensions.width;
      newX = state.position.x - offsetX * (sizeRatio - 1);
      newY = state.position.y - offsetY * (sizeRatio - 1);
    }
    const containerWidth = containerRef.current.offsetWidth;
    const containerHeight = containerRef.current.offsetHeight;
    const isWiderThanContainer = boundedWidth - containerWidth >= 1;
    const isTallerThanContainer = boundedHeight - containerHeight >= 1;
    if (isWiderThanContainer) {
      const maxX = (boundedWidth - containerWidth) / 2;
      const minX = -maxX;
      newX = Math.min(Math.max(newX, minX), maxX);
    } else {
      newX = 0;
    }
    if (isTallerThanContainer) {
      const maxY = (boundedHeight - containerHeight) / 2;
      const minY = -maxY;
      newY = Math.min(Math.max(newY, minY), maxY);
    } else {
      newY = 0;
    }
    state.position = {
      x: newX,
      y: newY
    };
    state.imageDimensions = {
      width: boundedWidth,
      height: boundedHeight
    };
    updateTransform();
    updateContainerCursor();
  };
  const zoomIn = () => {
    const state = stateRef.current;
    if (!state.initialDimensions.width) return;
    const absoluteMaxWidth = state.initialDimensions.width * maxScale;
    if (state.imageDimensions.width >= absoluteMaxWidth) return;
    const relativeZoomStep = state.initialDimensions.width * zoomStep;
    handleZoom(state.imageDimensions.width + relativeZoomStep);
  };
  const zoomOut = () => {
    const state = stateRef.current;
    if (!state.initialDimensions.width) return;
    if (state.imageDimensions.width <= state.initialDimensions.width) return;
    const relativeZoomStep = state.initialDimensions.width * zoomStep;
    handleZoom(state.imageDimensions.width - relativeZoomStep);
  };
  const resetTransform = () => {
    const state = stateRef.current;
    if (!state.initialDimensions.width || !state.initialDimensions.height) return;
    if (state.imageDimensions.width === state.initialDimensions.width && state.position.x === 0 && state.position.y === 0) return;
    state.imageDimensions = {
      ...state.initialDimensions
    };
    state.position = {
      x: 0,
      y: 0
    };
    updateTransform();
    updateContainerCursor();
  };
  const handleWheel = e => {
    e.preventDefault();
    e.stopPropagation();
    const state = stateRef.current;
    if (!state.imageDimensions.width) return;
    if (e.ctrlKey) {
      const zoomFactor = 1 - e.deltaY * 0.01;
      const newWidth = state.imageDimensions.width * zoomFactor;
      handleZoom(newWidth, e.clientX, e.clientY);
      return;
    }
    const delta = -e.deltaY * 0.001;
    const sizeChange = state.imageDimensions.width * delta;
    const newWidth = state.imageDimensions.width + sizeChange;
    handleZoom(newWidth, e.clientX, e.clientY);
  };
  const getTouchDistance = touches => {
    const dx = touches[0].clientX - touches[1].clientX;
    const dy = touches[0].clientY - touches[1].clientY;
    return Math.sqrt(dx * dx + dy * dy);
  };
  const handleTouchStart = e => {
    const state = stateRef.current;
    if (e.touches.length === 2) {
      e.preventDefault();
      state.isZooming = true;
      state.initialDistance = getTouchDistance(e.touches);
    } else if (e.touches.length === 1) {
      if (!containerRef.current || !state.imageDimensions.width || !state.imageDimensions.height) return;
      const containerWidth = containerRef.current.offsetWidth;
      const containerHeight = containerRef.current.offsetHeight;
      const imgWidth = state.imageDimensions.width;
      const imgHeight = state.imageDimensions.height;
      const isOverflowing = imgWidth - containerWidth >= 1 || imgHeight - containerHeight >= 1;
      if (!isOverflowing) return;
      state.isDragging = true;
      state.dragStart = {
        x: e.touches[0].clientX - state.position.x,
        y: e.touches[0].clientY - state.position.y
      };
      updateContainerCursor();
    }
  };
  const handleTouchMove = e => {
    const state = stateRef.current;
    if (e.touches.length === 2 && state.isZooming) {
      e.preventDefault();
      const currentDistance = getTouchDistance(e.touches);
      const sizeDelta = currentDistance / state.initialDistance;
      const newWidth = state.imageDimensions.width * sizeDelta;
      const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
      const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
      handleZoom(newWidth, centerX, centerY);
      state.initialDistance = currentDistance;
    } else if (e.touches.length === 1 && state.isDragging && containerRef.current) {
      let newX = e.touches[0].clientX - state.dragStart.x;
      let newY = e.touches[0].clientY - state.dragStart.y;
      if (state.imageDimensions.width && state.imageDimensions.height) {
        const containerWidth = containerRef.current.offsetWidth;
        const containerHeight = containerRef.current.offsetHeight;
        const imgWidth = state.imageDimensions.width;
        const imgHeight = state.imageDimensions.height;
        const isWider = imgWidth - containerWidth >= 1;
        const isTaller = imgHeight - containerHeight >= 1;
        if (isWider) {
          const halfOverflowX = (imgWidth - containerWidth) / 2;
          newX = Math.min(Math.max(newX, -halfOverflowX), halfOverflowX);
        } else {
          newX = 0;
        }
        if (isTaller) {
          const halfOverflowY = (imgHeight - containerHeight) / 2;
          newY = Math.min(Math.max(newY, -halfOverflowY), halfOverflowY);
        } else {
          newY = 0;
        }
      }
      state.position = {
        x: newX,
        y: newY
      };
      updateTransform();
    }
  };
  const handleTouchEnd = e => {
    const state = stateRef.current;
    if (e.touches.length < 2) {
      state.isZooming = false;
    }
    if (e.touches.length === 0) {
      state.isDragging = false;
      updateContainerCursor();
    }
  };
  React.useEffect(() => {
    const handleEscape = e => {
      if (e.key === 'Escape' && showLightbox) {
        closeLightbox();
      }
    };
    const preventScroll = e => {
      e.preventDefault();
      e.stopPropagation();
      return false;
    };
    if (showLightbox) {
      const originalOverflow = document.body.style.overflow;
      const originalPosition = document.body.style.position;
      const scrollY = window.scrollY;
      document.body.style.overflow = 'hidden';
      document.body.style.position = 'fixed';
      document.body.style.top = `-${scrollY}px`;
      document.body.style.width = '100%';
      window.addEventListener('wheel', preventScroll, {
        passive: false
      });
      window.addEventListener('touchmove', preventScroll, {
        passive: false
      });
      window.addEventListener('scroll', preventScroll, {
        passive: false
      });
      document.addEventListener('keydown', handleEscape);
      document.addEventListener('mousemove', handleMouseMove);
      document.addEventListener('mouseup', handleMouseUp);
      return () => {
        document.body.style.overflow = originalOverflow;
        document.body.style.position = originalPosition;
        document.body.style.top = '';
        document.body.style.width = '';
        window.scrollTo(0, scrollY);
        window.removeEventListener('wheel', preventScroll);
        window.removeEventListener('touchmove', preventScroll);
        window.removeEventListener('scroll', preventScroll);
        document.removeEventListener('keydown', handleEscape);
        document.removeEventListener('mousemove', handleMouseMove);
        document.removeEventListener('mouseup', handleMouseUp);
      };
    }
  }, [showLightbox]);
  React.useEffect(() => {
    if (!showLightbox || !containerRef.current) return;
    const el = containerRef.current;
    const state = stateRef.current;
    const onGestureStart = e => {
      e.preventDefault();
      e.stopPropagation();
      state.isZooming = true;
      state.gestureBaseWidth = state.imageDimensions.width || state.initialDimensions.width || 0;
    };
    const onGestureChange = e => {
      e.preventDefault();
      e.stopPropagation();
      if (!state.gestureBaseWidth) return;
      const newWidth = state.gestureBaseWidth * e.scale;
      const cx = e.clientX ?? el.getBoundingClientRect().left + el.getBoundingClientRect().width / 2;
      const cy = e.clientY ?? el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2;
      handleZoom(newWidth, cx, cy);
    };
    const onGestureEnd = e => {
      e.preventDefault();
      e.stopPropagation();
      state.isZooming = false;
      state.gestureBaseWidth = 0;
    };
    const onWheelCapture = e => {
      if (e.ctrlKey) {
        e.preventDefault();
        e.stopPropagation();
        const zoomFactor = 1 - e.deltaY * 0.01;
        const newWidth = state.imageDimensions.width * zoomFactor;
        handleZoom(newWidth, e.clientX, e.clientY);
      }
    };
    el.addEventListener('gesturestart', onGestureStart, {
      passive: false
    });
    el.addEventListener('gesturechange', onGestureChange, {
      passive: false
    });
    el.addEventListener('gestureend', onGestureEnd, {
      passive: false
    });
    el.addEventListener('wheel', onWheelCapture, {
      passive: false
    });
    return () => {
      el.removeEventListener('gesturestart', onGestureStart);
      el.removeEventListener('gesturechange', onGestureChange);
      el.removeEventListener('gestureend', onGestureEnd);
      el.removeEventListener('wheel', onWheelCapture);
    };
  }, [showLightbox]);
  return <>
            <div className="flex flex-col">
                <div onClick={e => {
    e.preventDefault();
    e.stopPropagation();
  }}>
                    <Frame caption={caption}>
                        <div onClick={handleImageClick} onKeyDown={e => {
    if (e.key === 'Enter' || e.key === ' ') {
      handleImageClick(e);
    }
  }} role="button" tabIndex={0} aria-label={`Enlarge diagram: ${caption}`} className="cursor-zoom-in" style={{
    display: 'block',
    position: 'relative'
  }}>
                            <img ref={imgRef} src={imageURL} alt={caption || imageName} className="w-full h-auto" style={{
    pointerEvents: 'none'
  }} />
                        </div>
                    </Frame>
                </div>
                <div className="flex justify-end my-2">
                    <Tooltip tip="Download image">
                        <button type="button" aria-label={`Download ${caption}`} onClick={handleDownload} className="bg-white text-black text-xs px-3 py-2 rounded-md border border-gray-300 hover:bg-gray-100 transition-all cursor-pointer">
                            <Icon icon="arrow-down-to-line" iconType="regular" color="black" /> 
                        </button>
                    </Tooltip>
                </div>
            </div>

            {showLightbox && <div role="dialog" aria-modal="true" aria-label={caption} style={{
    position: 'fixed',
    top: 0,
    left: 0,
    width: '100%',
    height: '100%',
    backgroundColor: 'rgba(0, 0, 0, 0.95)',
    zIndex: 999999,
    display: 'flex',
    alignItems: 'center',
    cursor: 'default',
    justifyContent: 'center',
    padding: `${Math.round(viewportPadding / 2)}px`,
    touchAction: 'none'
  }} onWheel={e => {
    e.preventDefault();
    e.stopPropagation();
  }}>
                    <div ref={containerRef} style={{
    position: 'relative',
    width: '100%',
    height: '100%',
    minWidth: `calc(100vw - ${viewportPadding}px)`,
    minHeight: `calc(100vh - ${viewportPadding}px)`,
    maxWidth: '100%',
    maxHeight: '100%',
    overflow: 'hidden',
    cursor: 'wait',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    borderRadius: '15px',
    border: '2px solid rgba(255, 255, 255, 0.3)',
    touchAction: 'none',
    backgroundColor: containerBackground,
    userSelect: 'none',
    WebkitUserSelect: 'none',
    MozUserSelect: 'none',
    msUserSelect: 'none',
    WebkitTouchCallout: 'none',
    WebkitTapHighlightColor: 'transparent'
  }} onMouseDown={handleMouseDown} onWheel={handleWheel} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd}>
                        <div className="caption-container" style={{
    position: 'absolute',
    left: '20px',
    top: '20px',
    zIndex: 1000000,
    backgroundColor: 'rgba(36, 36, 36, 0.76)',
    padding: '10px',
    borderRadius: '15px',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    pointerEvents: 'none'
  }}>
                            <p className="caption-text" style={{
    fontSize: '16px',
    fontWeight: 'bold',
    color: 'white',
    marginLeft: '10px'
  }}>
                                {caption}
                            </p>
                        </div>
                        <button type="button" aria-label="Close enlarged view" onClick={closeLightbox} onMouseDown={e => e.stopPropagation()} style={{
    position: 'absolute',
    top: '20px',
    right: '20px',
    background: 'rgba(36, 36, 36, 0.76)',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    color: 'white',
    fontSize: '24px',
    width: '40px',
    height: '40px',
    borderRadius: '50%',
    cursor: 'pointer',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    transition: 'background 0.2s',
    zIndex: 1000000
  }} onMouseEnter={e => {
    e.target.style.background = 'rgba(36, 36, 36, 0.9)';
  }} onMouseLeave={e => {
    e.target.style.background = 'rgba(36, 36, 36, 0.76)';
  }}>
                            ×
                        </button>
                        <div style={{
    position: 'absolute',
    bottom: '20px',
    left: '20px',
    display: 'flex',
    gap: '10px',
    zIndex: 1000000
  }}>
                            <button type="button" aria-label="Zoom in" ref={zoomInBtnRef} onClick={e => {
    e.stopPropagation();
    zoomIn();
  }} onMouseDown={e => e.stopPropagation()} style={{
    background: 'rgba(36, 36, 36, 0.76)',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    color: 'white',
    fontSize: '20px',
    width: '40px',
    height: '40px',
    borderRadius: '8px',
    cursor: 'pointer',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    transition: 'background 0.2s',
    opacity: 1
  }} onMouseEnter={e => {
    if (!e.target.disabled) e.target.style.background = 'rgba(36, 36, 36, 0.9)';
  }} onMouseLeave={e => {
    e.target.style.background = 'rgba(36, 36, 36, 0.76)';
  }}>
                                +
                            </button>
                            <button type="button" aria-label="Zoom out" ref={zoomOutBtnRef} onClick={e => {
    e.stopPropagation();
    zoomOut();
  }} onMouseDown={e => e.stopPropagation()} style={{
    background: 'rgba(36, 36, 36, 0.76)',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    color: 'white',
    fontSize: '20px',
    width: '40px',
    height: '40px',
    borderRadius: '8px',
    cursor: 'not-allowed',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    transition: 'background 0.2s',
    opacity: 0.5,
    pointerEvents: 'auto'
  }} onMouseEnter={e => {
    if (e.target.style.opacity !== '0.5') {
      e.target.style.background = 'rgba(36, 36, 36, 0.9)';
    }
  }} onMouseLeave={e => {
    e.target.style.background = 'rgba(36, 36, 36, 0.76)';
  }}>
                                −
                            </button>
                            <button type="button" aria-label="Reset zoom" ref={resetBtnRef} onClick={e => {
    e.stopPropagation();
    resetTransform();
  }} onMouseDown={e => e.stopPropagation()} style={{
    background: 'rgba(36, 36, 36, 0.76)',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    color: 'white',
    fontSize: '16px',
    width: '40px',
    height: '40px',
    borderRadius: '8px',
    cursor: 'not-allowed',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    transition: 'background 0.2s',
    opacity: 0.5,
    pointerEvents: 'auto'
  }} onMouseEnter={e => {
    if (e.target.style.opacity !== '0.5') {
      e.target.style.background = 'rgba(36, 36, 36, 0.9)';
    }
  }} onMouseLeave={e => {
    e.target.style.background = 'rgba(36, 36, 36, 0.76)';
  }}>
                                ⟲
                            </button>
                            <div ref={scaleDisplayRef} style={{
    background: 'rgba(36, 36, 36, 0.76)',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    color: 'white',
    fontSize: '14px',
    padding: '0 12px',
    height: '40px',
    borderRadius: '8px',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    minWidth: '60px',
    pointerEvents: 'none'
  }}>
                                100%
                            </div>
                        </div>
                        <div ref={dragHintRef} className="drag-hint" style={{
    display: 'none',
    position: 'absolute',
    right: '20px',
    bottom: '20px',
    zIndex: 1000000,
    backgroundColor: 'rgba(36, 36, 36, 0.76)',
    padding: '10px 15px',
    borderRadius: '15px',
    border: '1px solid rgba(255, 255, 255, 0.3)',
    pointerEvents: 'none'
  }}>
                            <div style={{
    display: 'flex',
    alignItems: 'center',
    gap: '8px'
  }}>
                                <Icon icon="hand" iconType="regular" color="white" />
                                <p style={{
    fontSize: '12px',
    color: 'white',
    margin: 0
  }}>Drag to move image</p>
                            </div>
                        </div>
                        <div ref={imageTransformRef} style={{
    position: 'relative',
    transform: 'translate(0px, 0px)',
    transition: 'none',
    willChange: 'transform',
    flexShrink: 0,
    minWidth: 0,
    minHeight: 0
  }}>
                            <img src={imageURL} alt={caption || imageName} onLoad={e => {
    const state = stateRef.current;
    const naturalWidth = e.target.naturalWidth;
    const naturalHeight = e.target.naturalHeight;
    if (!containerRef.current) return;
    const containerWidth = containerRef.current.offsetWidth;
    const containerHeight = containerRef.current.offsetHeight;
    const scaleX = containerWidth / naturalWidth;
    const scaleY = containerHeight / naturalHeight;
    const initialScale = Math.min(scaleX, scaleY, 1);
    const initialWidth = naturalWidth * initialScale;
    const initialHeight = naturalHeight * initialScale;
    state.naturalDimensions = {
      width: naturalWidth,
      height: naturalHeight
    };
    state.initialDimensions = {
      width: initialWidth,
      height: initialHeight
    };
    state.imageDimensions = {
      width: initialWidth,
      height: initialHeight
    };
    setTimeout(() => {
      state.imageLoaded = true;
      updateTransform();
      updateContainerCursor();
      updateDragHint();
      if (e.target) {
        e.target.style.opacity = '1';
      }
    }, 100);
  }} style={{
    display: 'block',
    width: 'auto',
    height: 'auto',
    maxWidth: 'none',
    maxHeight: 'none',
    opacity: 0,
    transition: 'opacity 0.2s ease-in-out',
    pointerEvents: 'none',
    userSelect: 'none',
    WebkitUserSelect: 'none',
    MozUserSelect: 'none',
    msUserSelect: 'none',
    WebkitTouchCallout: 'none',
    WebkitTapHighlightColor: 'transparent',
    WebkitDrag: 'none',
    WebkitUserDrag: 'none',
    KhtmlUserSelect: 'none'
  }} draggable="false" />
                        </div>
                    </div>
                </div>}
        </>;
};

## Passkey Creation for MFA User Accounts

MFA users logging in via desktop and mobile browsers follow a standard login flow where they are offered the option to append a passkey.

<ToolingFrame imageURL="/images/authentication-flow/web-app/desktop/mfa-append-macos.webp" caption="Representative web passkey creation flow for an MFA account shown on macOS" imageName="mfa-append-macos.webp" />

<Info>
  The blue background indicates product-specific steps, such as a verification
  code. Passkey creation occurs within the same application flow.
</Info>

<Steps>
  <Step title="Login when user has no passkeys and has activated MFA">
    * User enters their identifier (email/username) on the login page.
  </Step>

  <Step title="Fallback to traditional login method">
    * If no passkey exists for the account, the system falls back to the traditional login method (username and password).
  </Step>

  <Step title="Complete configured additional-factor challenge">
    * After the first sign-in step, the user completes every additional factor required by the account's active policy. The benchmark does not prescribe email, SMS, an authenticator app, push, a security key or another specific factor.
  </Step>

  <Step title="Passkey append process initiates">
    * Once MFA succeeds, the application shows a manual creation nudge that explains the future sign-in benefit.
    * A quality implementation can first open one creation ceremony automatically. If the user cancels it, the manual nudge appears without an error and waits for the user to click Continue before trying again.
  </Step>

  <Step title="System passkey-creation sheet appears">
    * User authorizes creation with a platform-supported device-unlock method.
  </Step>

  <Step title="Passkey creation successful">
    * User successfully creates a passkey and it is now linked with the user account for the respective web-app.
  </Step>

  <Step title="Redirection to app dashboard">
    * User is redirected to the app dashboard.
  </Step>
</Steps>

## Acceptance criteria

| Benchmark as | User reach |
| ------------ | ---------- |
| Strategy     | High       |

<Note>
  The relying party must define whether a passkey replaces the existing
  multi-step sign-in or acts as one factor within it. The acceptance criteria
  preserve either policy; they do not assume a specific MFA method.
</Note>

| ID            | Level   | Acceptance criterion                                                                                                                                                                                                                                                                             | How to verify                                                                                                                                                                                                                             |
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **W2.3-AC01** | Core    | The creation offer appears only after the account's complete existing MFA policy succeeds.                                                                                                                                                                                                       | Test successful MFA, failed and abandoned additional-factor challenges, an expired MFA session, and any configured remembered-device path. The offer appears only after the policy reports complete success.                              |
| **W2.3-AC02** | Core    | The authentication policy states whether the passkey replaces the existing multi-factor sign-in or acts as one factor within it.                                                                                                                                                                 | Inspect the configured policy and its next-sign-in tests. Confirm that one supported role is defined for each included account group.                                                                                                     |
| **W2.3-AC03** | Core    | After the account's MFA sign-in completes, show a manual creation nudge that explains how a passkey improves the next sign-in, unless a passkey was already created by the automatic ceremony in W2.3-AC05. In the manual flow, the creation ceremony opens only after the user clicks Continue. | Complete MFA. Confirm that the manual nudge explains the future sign-in benefit and that the manual flow opens one creation ceremony only after Continue is clicked. One automatic ceremony under W2.3-AC05 does not fail this criterion. |
| **W2.3-AC04** | Core    | When the passkey substitutes the existing multi-factor journey, registration and later authentication require and verify local user verification.                                                                                                                                                | Register and authenticate with UV present, then repeat with UV absent or not requested. Only the UV-enforced path satisfies the substitution policy.                                                                                      |
| **W2.3-AC05** | Quality | On suitable clients, open one passkey-creation ceremony automatically after MFA completes. If it is cancelled or fails, show the manual creation nudge without an application error and wait for the user to click Continue before retrying.                                                     | Confirm that one ceremony opens automatically on a suitable client after MFA. Cancel or fail it, then confirm that the manual nudge appears without an error and no ceremony reopens until Continue is clicked.                           |

### References

* **Relevant criteria:** **W2.3-AC01, W2.3-AC03 and W2.3-AC05**: [How to get high passkey adoption in creation flows](https://www.corbado.com/blog/passkey-creation-best-practices) covers post-MFA enrollment timing, value messaging and automatic creation.
* **Relevant criteria:** **W2.3-AC01 and W2.3-AC03**: [Passkeys user journeys](https://developers.google.com/identity/passkeys/ux/user-journeys) covers offering passkey creation after completed authentication and explaining its future sign-in benefit.
* **Relevant criteria:** **W2.3-AC02 and W2.3-AC04**: [NIST SP 800-63B: Syncable authenticators](https://pages.nist.gov/800-63-4/sp800-63b/syncable/) covers the authenticator's policy role and enforcing user verification accordingly.
