flutter_mfa_qr
Optional camera scanner for otpauth:// QR codes, part of flutter_mfa. On the web it uses the native BarcodeDetector and loads no remote decoder unless you opt in.
Status: v0.2.0, the first public release. Until 1.0, a minor version may change the API; the CHANGELOG says what changed.
Built and maintained by BreaktroughF1 LLP in collaboration with ARDC, CHRIST (Deemed to be University). MIT licensed.
Security model and design: see the project docs and ADR-0005.
Install
flutter pub add flutter_mfa_qr
Usage
OtpQrScanner(
onScanned: (uri) async {
// The URI holds the account secret: enrol it and drop it.
await client.enrollFromUri(uri.toUri().toString());
if (context.mounted) Navigator.of(context).pop();
},
onRejected: (_) => showNotAnAuthenticatorCode(), // never log the payload
onOpenSettings: openAppSettings, // for example from package:app_settings
)
Give the scanner a bounded size (it fills its parent), and always offer manual entry of the setup key next to it.
- Only valid
otpauth://URIs reachonScanned; anything else goes toonRejected. A code held in front of the camera is reported once. - Payloads are never logged. Camera frames are not kept. For de-duplication only a SHA-256 digest of the last payload is held.
- The text passed to
onRejectedcan contain a secret (for example a malformed enrolment URI). Do not log it or send it anywhere.
When the camera cannot be shown
Instead of the camera, the scanner shows a localized message (English, Hindi, Arabic via MfaLocalizations) with actions. Replace it with permissionBuilder, which receives an OtpQrScannerStatus and a retry callback. mfaStrings(context) from flutter_mfa_ui gives you the built-in texts.
| Status | Meaning | Built-in actions |
|---|---|---|
requesting |
The camera is starting; the permission prompt may be open. | none |
denied |
The user declined. Asking again shows the prompt (Android only). | Try again |
permanentlyDenied |
The system will not ask again; only the settings can allow it. | Open settings (if onOpenSettings is set), Try again |
unavailable |
No usable camera: none present, in use elsewhere, or it failed to start. | Try again |
unsupportedBrowser |
Web, default options: the browser has no built-in QR decoder. The camera is never requested. | none |
insecureContext |
Web page not served over HTTPS: the camera is never requested. | none |
On iOS and in browsers a denial is permanent. Android shows the prompt at most twice and does not say whether it will ask again, so the first denial there is denied and a denial after "Try again" is permanentlyDenied. In the permanentlyDenied state the scanner retries when the app returns to the foreground, so it picks up access granted in the settings.
Platform setup
Android. The CAMERA permission is merged in from mobile_scanner (minSdk 24 here). Decoding uses Google's on-device ML Kit model, which adds a few MB to the APK. To use the Google Play services model instead, set dev.steenbakker.mobile_scanner.useUnbundled=true in android/gradle.properties. ML Kit can send usage and performance metrics to Google. Neither images nor payloads are sent. Check Google's ML Kit data-disclosure guidance when you fill in the Play data-safety form.
iOS. Add NSCameraUsageDescription to ios/Runner/Info.plist, for example "Scan the QR code to add an account". Decoding uses Apple's Vision framework, on device.
Web. The camera needs a secure context (HTTPS or localhost). If you send a Permissions-Policy header, it must allow camera=(self). Decoding is chosen with webOptions:
OtpQrWebOptions |
Decoder | Loads | Content-Security-Policy additions |
|---|---|---|---|
| default | the browser's BarcodeDetector (Chrome, Edge, Safari 17+) |
nothing | none |
decoderScriptUrl: Uri.parse('zxing/index.min.js') |
@zxing/library 0.23.0 UMD build (Apache-2.0), self-hosted |
that one file | script-src 'self' (or the https host you serve it from) |
allowRemoteDecoder: true |
BarcodeDetector, else zxing-wasm 3.1.3 |
script from cdn.jsdelivr.net, WebAssembly from fastly.jsdelivr.net |
script-src https://cdn.jsdelivr.net 'wasm-unsafe-eval'; connect-src https://fastly.jsdelivr.net |
With the default, browsers without BarcodeDetector (Firefox, and Chrome on Windows and Linux according to MDN, at the time of writing) show unsupportedBrowser without asking for the camera, and users enter the setup key instead.
To scan in every browser without third-party code, self-host the decoder:
- Copy
umd/index.min.jsfrom the npm package@zxing/library@0.23.0toweb/zxing/index.min.js. Check that its SHA-256 is3ede94153fb0c5b67a12d7adff6decd827c2b22714fdc6faecf27a8f20937ea6. It loads no further files. - Pass
OtpQrWebOptions(decoderScriptUrl: Uri.parse('zxing/index.min.js')).
decoderScriptUrl must be an https URL or a same-origin path; anything else throws ArgumentError. allowRemoteDecoder runs code you do not control in the origin that holds your users' secrets, so prefer the self-hosted script. These settings apply to the whole page, and the script is loaded once, so use the same options for every scanner.
Libraries
- flutter_mfa_qr
- Optional camera scanner for
otpauth://QR codes (ADR-0005).