> ## 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)

> Native 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

After the account's complete MFA sign-in, the app can offer passkey creation for future sign-ins.

<Tabs>
  <Tab title="iOS">
    <ToolingFrame imageURL="/images/authentication-flow/native-app/mfa-append-ios-native.webp" caption="Native: iOS passkey append, MFA accounts" imageName="mfa-append-ios-native.webp" />
  </Tab>

  <Tab title="Android">
    <ToolingFrame imageURL="/images/authentication-flow/native-app/mfa-append-android-native.webp" caption="Native: Android passkey append, MFA accounts" imageName="mfa-append-android-native.webp" />
  </Tab>
</Tabs>

<Note>
  Blue steps in the diagram are product-specific and are not part of the passkey benchmark.
</Note>

<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 the configured MFA policy">
    * After the primary factor, the user completes every additional factor required by the account's current policy.
  </Step>

  <Step title="Passkey append process initiates">
    * Show a manual nudge that explains how a passkey improves the next sign-in.
    * A quality implementation can first open one creation ceremony automatically. If it is cancelled or fails, show the manual nudge without an error.
  </Step>

  <Step title="System passkey-creation sheet appears">
    * In the manual flow, the sheet appears after the user clicks Continue.
    * The platform asks the user to authorize creation with a 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 native app.
  </Step>

  <Step title="Continue to the app">
    * Skipping the optional nudge continues without a passkey.
    * After the configured limit of cancelled or failed attempts, the app continues without another prompt or error.
  </Step>
</Steps>

## Acceptance criteria

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

This flow covers passkey enrollment after the account's complete MFA sign-in.

| ID | Level | Acceptance criterion | How to verify |
| - | - | - | - |
| **N2.2-AC01** | Core | Show the creation offer 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. |
| **N2.2-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. |
| **N2.2-AC03** | Core | After MFA 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 N2.2-AC05. The user can continue to creation or continue to the app without a passkey. | Complete MFA. Confirm that the nudge explains the future sign-in benefit, Continue opens one creation ceremony, and skipping continues without creating a passkey. One automatic ceremony under N2.2-AC05 does not fail this criterion. |
| **N2.2-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 user verification present, then repeat with it absent or not requested. Only the user-verified path satisfies the substitution policy. |
| **N2.2-AC05** | Quality | On suitable clients, open one creation ceremony automatically after MFA completes. If it is cancelled or fails before the configured attempt limit is reached, show the manual nudge without an application error and wait for the user to choose another attempt. Once the limit is reached, continue silently without another ceremony, nudge or error. | Cancel or fail the automatic attempt below the limit and confirm that the manual nudge appears without an error or immediate reopen. Reach the limit and confirm that the signed-in journey continues without another ceremony, nudge or error. |

### References

* **Relevant criteria:** **N2.2-AC01, N2.2-AC03 and N2.2-AC05:** [Corbado: How to get high passkey adoption in creation flows](https://www.corbado.com/blog/passkey-creation-best-practices) covers post-MFA enrollment, manual and automatic creation, and retry limits.
* **Relevant criteria:** **N2.2-AC01 and N2.2-AC03:** [Google: 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:** **N2.2-AC02 and N2.2-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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.