Pin input screens are deceptively hard in Flutter. Between SMS OTP autofill, biometric fallback, paste handling, accessibility, and platform-specific keyboards, the typical implementation has 6 subtle bugs that take a week to find. This guide walks through 6 production patterns that cover the common use cases: phone verification, email OTP, app lock, paywall gate, parental lock, and in-app sensitive action. Each with full code that works today.
Short version: use the pinput package for phone and email OTP because it handles SMS autofill correctly on both iOS and Android. Use local_auth plus a SharedPrefs- backed PIN for app lock. Combine both with a clean state machine and the result is bug-free.
When pin input actually matters
| Use case | Length | Input source | Autofill? |
|---|---|---|---|
| Phone OTP (SMS verification) | 4 or 6 digits | SMS message | Yes, native |
| Email OTP (login code) | 6 digits | iOS autofill from Mail app | |
| App lock (biometric fallback) | 4 or 6 digits | User input | No |
| Paywall PIN (parental gate) | 4 digits | User input | No |
| In-app sensitive action | 4 or 6 digits | User input | No |
| Two-factor auth | 6 digits | Authenticator app | iOS autofill from Messages |
Pattern 1: Phone OTP with SMS autofill (pinput)
The most common pin input scenario. The user enters a phone number, receives an SMS with a 6-digit code, and taps the code into the input. With pinput plus proper SMS autofill, the code appears automatically.
# pubspec.yaml
dependencies:
pinput: ^5.0.0
sms_autofill: ^2.4.0 # Android-only, for SMS retriever
firebase_auth: ^5.4.0 # if using Firebase phone authimport 'package:pinput/pinput.dart';
import 'package:sms_autofill/sms_autofill.dart';
class PhoneOtpScreen extends StatefulWidget {
@override
State<PhoneOtpScreen> createState() => _PhoneOtpScreenState();
}
class _PhoneOtpScreenState extends State<PhoneOtpScreen> with CodeAutoFill {
final _controller = TextEditingController();
@override
void initState() {
super.initState();
listenForCode(); // Android SMS retriever
}
@override
void codeUpdated() {
setState(() => _controller.text = code ?? '');
}
@override
void dispose() {
cancel();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final defaultPinTheme = PinTheme(
width: 56,
height: 64,
textStyle: theme.textTheme.headlineSmall,
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(12),
),
);
return Scaffold(
appBar: AppBar(title: const Text('Enter verification code')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('We sent a 6-digit code to your phone.', style: theme.textTheme.bodyLarge),
const SizedBox(height: 24),
Pinput(
length: 6,
controller: _controller,
defaultPinTheme: defaultPinTheme,
focusedPinTheme: defaultPinTheme.copyWith(
decoration: defaultPinTheme.decoration!.copyWith(
border: Border.all(color: theme.colorScheme.primary, width: 2),
),
),
onCompleted: (code) => _verify(code),
androidSmsAutofillMethod: AndroidSmsAutofillMethod.smsRetrieverApi,
),
]),
),
);
}
Future<void> _verify(String code) async {
// Call FirebaseAuth.signInWithCredential or your backend here
}
}Three things to get right:
- iOS uses a shared cross-app autofill. When iOS detects a code in Messages, the keyboard suggestion bar offers it automatically. You do not need any code for this; iOS reads the field's
textContentTypewhichpinputsets correctly by default. - Android uses the SMS Retriever API. Register the app's SMS hash and include it in the message body.
sms_autofillplus the SMS Retriever pattern is the privacy-respecting way (no SMS-read permission needed). - 6-digit codes are the 2026 standard. 4-digit codes are too guessable; 8+ feel excessive. Firebase and Twilio both default to 6.
Pattern 2: Email OTP (login code via email)
Passwordless email auth has replaced password-based login in many 2026 indie apps. Same UI as phone OTP, just different backend. The only autofill is iOS which reads codes from the Mail app.
Pinput(
length: 6,
keyboardType: TextInputType.number,
onCompleted: (code) async {
final user = await api.verifyEmailCode(email, code);
if (!context.mounted) return;
context.go('/home');
},
),Pattern 3: App lock with biometric fallback
Users expect to unlock the app with Face ID, Touch ID, or fingerprint. If biometric fails three times or the user prefers not to use it, fall back to a PIN. The PIN is stored hashed in secure storage.
# pubspec.yaml
dependencies:
local_auth: ^2.2.0
flutter_secure_storage: ^10.0.0
crypto: ^3.0.6class AppLockService {
final _auth = LocalAuthentication();
final _storage = const FlutterSecureStorage();
Future<bool> unlockWithBiometric() async {
final canUse = await _auth.canCheckBiometrics;
if (!canUse) return false;
return _auth.authenticate(
localizedReason: 'Unlock the app',
options: const AuthenticationOptions(biometricOnly: true),
);
}
Future<bool> unlockWithPin(String pin) async {
final storedHash = await _storage.read(key: 'app_lock_pin_hash');
if (storedHash == null) return false;
final inputHash = sha256.convert(utf8.encode(pin)).toString();
return inputHash == storedHash;
}
Future<void> setPin(String pin) async {
final hash = sha256.convert(utf8.encode(pin)).toString();
await _storage.write(key: 'app_lock_pin_hash', value: hash);
}
}The app lock screen shows a biometric prompt on mount. If the user cancels or fails, the pin input appears. After three failed PIN attempts, add a 30-second cooldown. Never store the raw PIN; hash it with SHA-256 and store the hash in flutter_secure_storage.
Pattern 4: Paywall PIN (parental gate)
Kids apps sometimes need a parental gate before the user can access the paywall or settings. Apple and Google both require this for apps targeted at children.
The pattern is a simple 4-digit PIN the parent sets once during onboarding. Show it when the user tries to subscribe, change settings, or access mature content. Do not gate every paywall view; only gate the purchase action.
Future<bool> showParentalGate(BuildContext context) async {
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => const ParentalGateDialog(),
) ?? false;
}Pattern 5: In-app sensitive action (re-authenticate before destructive)
Common in banking, crypto, and pro-tier apps. Before the user deletes their account, transfers funds, or changes their email, prompt for the PIN again even if the app is already unlocked. Treat it like a "sudo" prompt on desktop.
Pattern 6: Two-factor authentication (6-digit time-based code)
For apps that integrate with Google Authenticator or similar. The UI is the same 6-digit pin input. The backend verifies the TOTP code. iOS autofills from the Messages app if the 2FA code was sent via SMS, otherwise the user types it.
Design variants: how the pin input actually looks
The pinput package supports all the common designs. Pick one that matches your brand.
| Style | Feel | Best for |
|---|---|---|
| Boxes (outlined rectangles) | Material 3 default, neutral | Most apps |
| Underline only | iOS-style, minimal | Native iOS feel |
| Filled dots (hidden) | Secure, passcode feel | App lock, sensitive actions |
| Circles | Friendly, kid-app | Parental gates |
| Compact pill | Dense, mobile-first | Small-screen apps |
Accessibility
- Every pin box needs a
Semantics(label: 'Digit 1 of 6')so screen readers announce position. - Support paste: users may copy a code from email.
pinputhandles this automatically; nativeTextFieldneeds a paste detector. - Keyboard should default to number pad (
TextInputType.number). - On iOS, set
textContentType: oneTimeCodeto enable SMS autofill. - Test with VoiceOver on iOS and TalkBack on Android.
Common failure modes
- SMS autofill not working on Android. The app's SMS hash is missing from the SMS body. Generate it with
signingReportand include it in the template. - iOS autofill suggestion not appearing. The field's
textContentTypeis not set tooneTimeCode.pinputhandles this; nativeTextFieldneeds it manual. - Paste includes the full SMS body, not just the code. Parse digits out of the pasted text before populating the field.
- Biometric fails silently. Check
isDeviceSupportedbeforecanCheckBiometrics; the former catches devices without biometric hardware. - PIN is easily brute-forced. Add exponential backoff after 3 failed attempts. Lock out for 60 seconds, then 5 minutes, then require biometric or email verification.
What The Flutter Kit ships
The Flutter Kit includes the 6 patterns above as ready-to-use screens: phone OTP, email OTP, app lock with biometric fallback, paywall PIN, sensitive-action re-auth, and 2FA. The pinput package is pre-configured with the brand theme, SMS autofill is wired for both iOS and Android, and the app lock flow uses local_authplus flutter_secure_storage out of the box.
$69 one-time, unlimited commercial projects. See every integration on the features page or jump to checkout.
Final recommendation
Use pinput for anything SMS or email OTP. Use local_auth plus SharedPrefs-hashed PIN for app lock. Don't try to build these from scratch withTextField widgets; the SMS-autofill subtleties alone will burn three days. Copy the six patterns above into your app and you're done in an afternoon.