> ## 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: In-App After Unlock

> In-app passkey prompt after local app unlock, including the possible consecutive device-verification interactions.

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>}
        </>;
};

## In-App Nudge: After Unlock (Post-Authentication)

This in-app prompt appears after users unlock the app and offers passkey creation for future authentication. The timing makes the prompt easy to notice, but it can cause consecutive device-verification interactions.

<Note>
  Local app unlock protects an existing session. Creating a passkey adds a credential for a future remote sign-in.
</Note>

<Tabs>
  <Tab title="iOS">
    <ToolingFrame imageURL="/images/authentication-flow/native-app/append-after-local-biometric-ios-native.webp" caption="Native: iOS In-App Nudge after unlock" imageName="append-after-local-biometric-ios-native.webp" />
  </Tab>

  <Tab title="Android">
    <ToolingFrame imageURL="/images/authentication-flow/native-app/append-after-local-biometric-android-native.webp" caption="Native: Android In-App Nudge after unlock" imageName="append-after-local-biometric-android-native.webp" />
  </Tab>
</Tabs>

<Steps>
  <Step title="App unlock using local protection">
    * User unlocks the app using a local biometric or an app/device PIN.
    * User is now authenticated within the app session.
  </Step>

  <Step title="In-App Nudge appears immediately after unlock">
    * After successful unlock and once the authenticated screen is stable, the app can show a dismissible creation nudge.
    * Readiness and nudge frequency follow the shared targeting policy.
    * This timing can place the system passkey-creation sheet immediately after local unlock. Some implementations use the [After CTA](/passkey-ui-flows/native/passkey-creation/in-app-after-cta) timing to separate the two interactions.
  </Step>

  <Step title="User chooses to create passkey">
    * If the user accepts, the system passkey-creation sheet appears.
    * The 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 native app.
    * The new passkey can be used for a future sign-in.
  </Step>
</Steps>

## Acceptance criteria

| Benchmark as | User reach |
| - | - |
| Strategy | Mid |

This strategy reaches users who stay signed in and therefore rarely complete a full login. It can, however, add a second device-verification interaction directly after local app unlock.

| ID | Level | Acceptance criterion | How to verify |
| - | - | - | - |
| **N2.4-AC01** | Core | Evaluate the flow only after local app unlock has completed and the authenticated session is stable. | Exercise successful, cancelled and failed unlocks plus rapid background and foreground transitions. The nudge becomes eligible only after successful unlock in a stable foreground state. |
| **N2.4-AC02** | Core | Start the platform registration ceremony only after the user accepts a dismissible in-app nudge. | Exercise accept, dismiss and back actions. Acceptance starts one ceremony; every dismissal path leaves the unlocked app usable without opening a system credential sheet. |
| **N2.4-AC03** | Core | Bind registration to the account authenticated when the ceremony starts and reject the response if that account changes before completion. After successful verification, store the credential for that account and confirm that it can complete a later sign-in. | Start creation, then switch accounts, sign out or supersede the session before returning the response. No changed or stale account context registers a credential. Complete a valid creation, sign out and confirm that the new passkey signs in to the same account. |

### References

* **Relevant criterion:** **N2.4-AC01:** [Native apps: Passkeys vs. local biometrics](https://www.corbado.com/blog/passkeys-local-biometrics) distinguishes local app unlock from remote authentication and covers post-unlock prompting.
* **Relevant criterion:** **N2.4-AC02:** [Passkeys user journeys](https://developers.google.com/identity/passkeys/ux/user-journeys) supports an explicit, dismissible user decision before passkey creation.
* **Relevant criterion:** **N2.4-AC03:** [Web Authentication: An API for accessing Public Key Credentials: Level 3](https://www.w3.org/TR/webauthn-3/) defines account and relying-party binding.


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