Skip to main content
Sovran supports optional passcode protection using a 4-digit PIN code. When enabled, the passcode must be entered each time you open the app.

Overview

The passcode is never persisted to disk. It only exists in memory during your session and protects against unauthorized physical access to your unlocked device.

How It Works

  1. PasscodeGate component wraps the app
  2. On launch, checks if passcode is set in settings store (memory-only)
  3. If set, displays PasscodeScreen until correct PIN is entered
  4. Once unlocked, app content is rendered
const PasscodeGate: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const passcode = useSettingsStore((state) => state.passcode);
  const [unlocked, setUnlocked] = useState(!passcode);

  useEffect(() => {
    if (!passcode) setUnlocked(true);
  }, [passcode]);

  if (passcode && !unlocked) {
    return <PasscodeScreen passcode={passcode} onSuccess={() => setUnlocked(true)} />;
  }

  return <>{children}</>;
};

Setting Up a Passcode

1

Navigate to Settings

Open the app and tap SettingsSecuritySet Passcode.
2

Enter Your PIN

Enter a 4-digit PIN code using the numeric keyboard.
3

Confirm Your PIN

Re-enter the same 4-digit code to confirm.
4

Passcode Active

Your passcode is now active and will be required on next app launch.
Forgetting your passcode will prevent you from accessing your wallet. The only recovery method is to uninstall and reinstall the app, then restore from your seed phrase.

Passcode Settings Screen

The passcode configuration flow has two steps:
Enter new passcode
  • Numeric keyboard with digits 0-9
  • 4 dots showing entry progress
  • Automatically advances to confirmation when 4 digits entered
Create Step (app/settings-pages/passcode.tsx:24-38)
const handlePress = (val: string) => {
  if (step === 'create') {
    if (val.length <= PASSCODE_LENGTH) {
      setCode(val);
      if (val.length === PASSCODE_LENGTH) {
        setStep('confirm');
        setKeyIdx((k) => k + 1);
      }
    }
  } else {
    if (val.length <= PASSCODE_LENGTH) {
      setConfirm(val);
    }
  }
};

Passcode Entry Screen

When the app is locked, users see:
Passcode entry screen
Features:
  • User avatar and welcome message
  • 4-dot progress indicator
  • Numeric keyboard (0-9 + backspace)
  • Shake animation on incorrect entry
  • Fade-out animation on success
const PasscodeScreen: React.FC<Props> = ({ passcode, onSuccess }) => {
  const { keys: nostrKeys } = useNostrKeysContext();
  const [value, setValue] = useState('');
  const [keyIdx, setKeyIdx] = useState(0);
  const opacity = useRef(new Animated.Value(1)).current;
  const shake = useRef(new Animated.Value(0)).current;

  const handlePress = (val: string) => {
    if (val.length > passcode.length) return;
    setValue(val);
    
    if (val.length === passcode.length) {
      if (val === passcode) {
        // Success: fade out and unlock
        Animated.timing(opacity, {
          toValue: 0,
          duration: 300,
          useNativeDriver: true,
        }).start(() => onSuccess());
      } else {
        // Failure: shake animation and reset
        Animated.sequence([
          Animated.timing(shake, { toValue: -10, duration: 50, useNativeDriver: true }),
          Animated.timing(shake, { toValue: 10, duration: 50, useNativeDriver: true }),
          Animated.timing(shake, { toValue: -10, duration: 50, useNativeDriver: true }),
          Animated.timing(shake, { toValue: 0, duration: 50, useNativeDriver: true }),
        ]).start();
        
        setTimeout(() => {
          setValue('');
          setKeyIdx((k) => k + 1);
        }, 200);
      }
    }
  };

  return (
    <BlurView className="bg-background flex-1">
      <Animated.View style={{ opacity, transform: [{ translateX: shake }] }}>
        <VStack align="center" justify="center" flex={1}>
          <Avatar seed={nostrKeys?.pubkey} size={80} />
          <Text>Welcome back, {getUsername(nostrKeys?.pubkey || '')}</Text>
          
          {/* Dot indicators */}
          <HStack>
            {Array.from({ length: passcode.length }).map((_, i) => (
              <View
                key={i}
                className={`mx-1.5 h-3 w-3 rounded-full ${
                  value.length > i ? 'bg-foreground' : 'border-foreground border'
                }`}
              />
            ))}
          </HStack>
          
          <NumericKeyboard onKeyPress={handlePress} />
        </VStack>
      </Animated.View>
    </BlurView>
  );
};

Numeric Keyboard

The custom numeric keyboard provides:
  • Digits 0-9: Standard numeric input
  • Backspace (⌫): Delete last digit
  • Haptic feedback: Button press and action haptics
  • Ripple animations: Visual feedback on tap
const NumericKeyboard: React.FC<Props> = ({ onKeyPress }) => {
  const inputRef = useRef('');

  const handlePress = useCallback(
    (value: KeyVal) => {
      let newValue: string;
      if (String(value) === '<') {
        EnhancedHaptics.actionHaptic();
        newValue = inputRef.current.slice(0, -1);
      } else {
        EnhancedHaptics.buttonHaptic();
        newValue = inputRef.current + String(value);
      }
      inputRef.current = newValue;
      onKeyPress(newValue);
    },
    [onKeyPress]
  );

  const buttons: KeyVal[][] = [
    ['1', '2', '3'],
    ['4', '5', '6'],
    ['7', '8', '9'],
    ['', '0', '<'],
  ];

  return (
    <VStack align="center">
      {buttons.map((row, rowIndex) => (
        <HStack key={rowIndex} justify="space-between">
          {row.map(renderButton)}
        </HStack>
      ))}
    </VStack>
  );
};

Security Characteristics

Memory-Only Storage

The passcode is never written to disk. It only exists in the Zustand store’s memory state.
From stores/settingsStore.ts:235-252:
Persistence Configuration
persist(
  (set, get) => ({ /* store definition */ }),
  {
    name: 'settings-store',
    storage: createJSONStorage(() => AsyncStorage),
    partialize: (state) => ({
      theme: state.theme,
      language: state.language,
      displayBtc: state.displayBtc,
      displayCurrency: state.displayCurrency,
      // ... other settings
      // NOTE: passcode is NOT included in persisted state
    }),
  }
)

Threat Model

Protects against unauthorized access to unlocked device
Prevents casual snooping by others nearby
Adds friction for physical theft scenarios
Does NOT protect against:
- Sophisticated attackers with device access
- Malware or keyloggers
- Forced biometric unlock
- Memory inspection attacks

Limitations

  1. Session-based: Passcode is lost when app is fully terminated
  2. 4-digit only: Limited entropy (10,000 possible combinations)
  3. No biometric option: PIN-only (biometrics planned for future)
  4. No rate limiting: Can attempt unlimited passcodes (mitigated by manual entry slowness)
For maximum security, combine passcode lock with full-disk encryption and device lock screen protection.

Resetting Your Passcode

If You Remember Current Passcode

1

Enter App

Unlock with your current passcode.
2

Clear Passcode

Go to SettingsSecurityClear Passcode.
3

Set New Passcode

Optionally set a new passcode immediately.

If You Forgot Your Passcode

There is no passcode recovery mechanism. You must reinstall the app and restore from seed phrase.
1

Ensure You Have Seed Phrase

Locate your 12-word recovery phrase backup. Without it, you will lose access to your funds.
2

Uninstall Sovran

Remove the app from your device. This clears all local data including the passcode.
3

Reinstall and Restore

Install Sovran fresh and restore from your seed phrase.
4

Set New Passcode

Configure a new passcode (optional).

Best Practices

Choose a Strong PIN

Avoid obvious codes like 1234, 0000, or your birth year.

Remember Your PIN

Use a memorable but non-obvious number. Write it down separately from your seed phrase.

Device Lock First

Enable device-level lock screen protection as primary security.

Seed Phrase Backup

Always maintain a secure backup of your seed phrase in case you need to reinstall.

Wallet Recovery

Restore from seed phrase if passcode is lost

Privacy Features

Other privacy protections in Sovran

Build docs developers (and LLMs) love