Flutter
Source:
en/integrations/flutter.md· Live: https://docs.1pass.dev/en/integrations/flutter LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L38-L46: ## Choosing a pattern
- L47-L101: ## SDK quick start (recommended, Public client)
- L102-L114: ## Dependencies (for manual integration)
- L115-L147: ## iOS Associated Domains
- L148-L193: ## Android App Links + custom scheme
- L194-L244: ## Flow A: BFF (Confidential)
- L245-L292: ## Flow B: Public client (PKCE-only) — manual (without the SDK)
- L293-L337: ## Token Storage (
flutter_secure_storage) - L338-L361: ## Biometric gating (
local_auth) - L362-L386: ## Device Bootstrap (
device_secret) - L387-L442: ## Troubleshooting
- L389-L424: ###
invalid_client(release build) - L425-L430: ### Universal Link not intercepted
- L431-L433: ###
invalid_grant: redirect_uri mismatch - L434-L436: ###
invalid_client: client credentials missing(public client) - L437-L439: ### Custom scheme opens the browser on tap
- L440-L442: ### Works on the Simulator, fails on a real device
- L389-L424: ###
- L443-L453: ## Review checklist
Flutter
flutter_secure_storage (token storage) + local_auth (biometric) + flutter_web_auth_2 (ASWebAuthenticationSession / Custom Tabs).
⚠️ Warning: Callback scheme rules
- Prefer a custom scheme:
com.example.app://oauth/1pass/callback. With a Universal Link (https://), an unrelated UL can be injected into the OAuth parser and cause a spuriousmissingCode(ainote, 2026-05-15). - iOS rejects underscore schemes:
my_app://❌ →myapp://✅ - Diagnostics: https://api.1pass.dev/diagnose
Choosing a pattern
| Scenario | client_type | Responsibility |
|---|---|---|
| Backend + Flutter (recommended) | confidential | BFF — passes the code; the backend handles secret/JWT |
| Flutter standalone | public | PKCE-only — exchanges tokens directly |
Details: Public vs Confidential.
SDK quick start (recommended, Public client)
The official Flutter SDK logi_auth bundles PKCE, nonce, and id_token signature verification (RS256, pointycastle) — giving public clients a defense layer without a backend; with a backend/BFF, the server re-verifying is the SoT for audience checks (see the BFF section below). You only inject the browser handoff via LogiAuthBrowser (a flutter_web_auth_2 adapter below). The SDK path is the recommended route; the Flow B: Public client manual code below is a fallback.
# pubspec.yaml
dependencies:
logi_auth: ^1.0.1
flutter_web_auth_2: ^4.0.0 # browser handoff adapterimport 'package:logi_auth/logi_auth.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
// LogiAuthBrowser implementation — opens the system browser, captures the redirect
class WebAuthBrowser extends LogiAuthBrowser {
@override
Future<Uri> authorize(Uri authorizeUrl, {required String callbackScheme}) async {
final result = await FlutterWebAuth2.authenticate(
url: authorizeUrl.toString(),
callbackUrlScheme: callbackScheme,
);
return Uri.parse(result);
}
}
final auth = LogiAuth(
config: const LogiAuthConfig(
clientId: 'logi_<your_public_client_id>',
redirectUri: 'com.example.app://oauth/1pass/callback',
// issuer defaults to https://api.1pass.dev · tokenIssuer defaults to "https://api.1pass.dev" (the id_token iss)
),
browser: WebAuthBrowser(),
);
Future<void> signIn() async {
try {
final session = await auth.signIn(); // -> LogiSession
// session.sub — verified subject (after RS256 signature + iss/aud/azp/exp/nonce)
// session.idToken / session.accessToken / session.refreshToken / session.email
} on LogiAuthException catch (e) {
// e.code: LogiAuthErrorCode (userCancelled, idTokenInvalid, jwksFetchFailed, ...)
}
}Since v1.0.1, signIn() wires the token response's access_token into id_token verification to confirm the at_hash binding (access_token substitution defense) before returning the session. Standalone id_token verification is verifyIdToken(idToken, jwks, expected, accessToken: ...) — pass accessToken to also verify at_hash when the id_token carries it (present-only, contract base64url_nopad(SHA256(access_token UTF-8 bytes)[first 16 bytes])). Token storage (flutter_secure_storage), biometric, and device bootstrap sections below still apply.
💡 Tip: id_token verification order (built into the SDK — identical across all SDKs)
alg==RS256→kid→JWKS → signature (RS256) →iss(=tokenIssuer"https://api.1pass.dev") →aud(must contain your client_id) →azp(required only when aud is multi-valued; value=client_id) →exp(60s skew) →iat→nonce(when requested) →sub→at_hash(present-only, v1.0.1).azpis conditional on a multi-audience token, not unconditional. at_hash (v1.0.1): when the id_token carriesat_hash, it is verified asbase64url_nopad(SHA256(access_token UTF-8 bytes)[first 16 bytes])(OIDC §3.1.3.6) to bind access_token↔id_token (rejected withatHashMismatch).signIn()wires the access_token automatically; standalone isverifyIdToken(idToken, jwks, expected, accessToken:). v1.0.1 selects the signing key with akty=="RSA"filter, robust to EC-key mix-in (JWKS).
⚠️ Warning: BFF (Confidential) is verified server-side The SDK above bundles id_token verification for a public client (Flutter standalone). With the BFF pattern, the backend performs the token exchange and verification — use Flow A: BFF below and verify on the server with
@logi-auth/server·logi_authgem ·logi-authpip. That server-side check is the SoT for audience verification.
Dependencies (for manual integration)
# pubspec.yaml
dependencies:
flutter_secure_storage: ^10.0.0
local_auth: ^2.3.0
flutter_web_auth_2: ^4.0.0
crypto: ^3.0.5Requirements: Flutter 3.16+, iOS 13+, Android API 23+.
iOS Associated Domains
ios/Runner/Runner.entitlements:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:app.example.com</string>
<string>webcredentials:app.example.com</string>
</array>https://app.example.com/.well-known/apple-app-site-association (Content-Type: application/json, no extension):
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.example.app",
"paths": ["/auth/logi/callback", "/auth/logi/device-callback"]
}
]
},
"webcredentials": {
"apps": ["TEAMID.com.example.app"]
}
}An AASA change takes ~24h to propagate through Apple's CDN + device cache. A reinstall is required.
Android App Links + custom scheme
android/app/src/main/AndroidManifest.xml:
<activity android:name=".MainActivity" ...>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.example.com" />
<data android:pathPrefix="/auth/logi/callback" />
</intent-filter>
</activity>
<activity
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
android:exported="true">
<intent-filter android:label="flutter_web_auth_2">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.example.app" />
</intent-filter>
</activity>https://app.example.com/.well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AB:CD:..."]
}
}
]keytool -list -v -keystore release.keystore | grep SHA256Flow A: BFF (Confidential)
Flutter only starts PKCE and receives the code. The token exchange happens on the backend.
Future<AuthResult> signInViaBff() async {
final state = _randomString(32);
final verifier = _randomString(64);
final challenge = _s256(verifier);
const redirectUri = 'com.example.app://auth/logi/callback';
final authUri = Uri.parse('https://api.1pass.dev/oauth/authorize').replace(
queryParameters: {
'response_type': 'code',
'client_id': 'logi_<your_confidential_client_id>',
'redirect_uri': redirectUri,
'scope': 'openid profile:basic email',
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
},
);
// callbackUrlScheme must match the redirect_uri scheme.
final callback = await FlutterWebAuth2.authenticate(
url: authUri.toString(),
callbackUrlScheme: 'com.example.app',
);
final cb = Uri.parse(callback);
if (cb.queryParameters['state'] != state) throw 'state mismatch';
final code = cb.queryParameters['code']!;
// The backend exchanges the code with logi and issues its own JWT
final resp = await http.post(
Uri.parse('https://api.example.com/api/auth/logi/exchange'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'code': code,
'code_verifier': verifier,
'redirect_uri': redirectUri, // byte-exact comparison
}),
);
final json = jsonDecode(resp.body);
return AuthResult(
accessToken: json['access_token'], // backend JWT (not the logi token)
refreshToken: json['refresh_token'],
user: json['user'],
);
}Flow B: Public client (PKCE-only) — manual (without the SDK)
A fallback for when you can't use the SDK quick start above. If you roll it by hand, you must also verify the id_token yourself or verify the access token via JWKS.
Future<Map<String, dynamic>> signInPublic() async {
const issuer = 'https://api.1pass.dev';
const clientId = 'logi_<your_public_client_id>';
const redirectUri = 'com.example.app://auth/callback';
final state = _randomString(32);
final verifier = _randomString(64);
final challenge = _s256(verifier);
final callback = await FlutterWebAuth2.authenticate(
url: Uri.parse('$issuer/oauth/authorize').replace(queryParameters: {
'response_type': 'code',
'client_id': clientId,
'redirect_uri': redirectUri,
'scope': 'openid profile:basic email',
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
}).toString(),
callbackUrlScheme: Uri.parse(redirectUri).scheme,
);
final cb = Uri.parse(callback);
if (cb.queryParameters['state'] != state) throw 'state mismatch';
// public client: no client_secret (rejected if sent)
final tokenResp = await http.post(
Uri.parse('$issuer/oauth/token'),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {
'grant_type': 'authorization_code',
'code': cb.queryParameters['code']!,
'redirect_uri': redirectUri,
'client_id': clientId,
'code_verifier': verifier,
},
);
return jsonDecode(tokenResp.body) as Map<String, dynamic>;
}Refresh token rotation: Public Clients docs.
Token Storage (flutter_secure_storage)
Always specify the options — the default syncs to iCloud on iOS.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class LogiTokenStorage {
static const _refreshKey = 'logi.refresh_token';
static const _deviceSecretKey = 'logi.device_secret';
final _storage = const FlutterSecureStorage(
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
// first_unlock_this_device: background access after first unlock + blocks iCloud sync
),
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
);
Future<void> saveRefreshToken(String token) =>
_storage.write(key: _refreshKey, value: token);
Future<String?> loadRefreshToken() => _storage.read(key: _refreshKey);
Future<void> saveDeviceSecret(String secret) =>
_storage.write(key: _deviceSecretKey, value: secret);
Future<String?> loadDeviceSecret() => _storage.read(key: _deviceSecretKey);
Future<void> clearAll() => _storage.deleteAll();
}Android backup exclusion (required):
<full-backup-content>
<exclude domain="sharedpref" path="FlutterSecureStorage.xml" />
</full-backup-content>
<application android:fullBackupContent="@xml/backup_rules" ...>Biometric gating (local_auth)
Instead of binding biometric directly to the token key, use a two-step "re-authenticate → use token" approach.
import 'package:local_auth/local_auth.dart';
final _auth = LocalAuthentication();
Future<bool> authenticateForSensitiveAction() async {
final canCheck = await _auth.canCheckBiometrics;
final isSupported = await _auth.isDeviceSupported();
if (!canCheck || !isSupported) return false;
return await _auth.authenticate(
localizedReason: 'Authenticate to delete your account',
options: const AuthenticationOptions(
biometricOnly: true,
stickyAuth: true,
),
);
}Device Bootstrap (device_secret)
- First call:
POST /api/v1/deviceswith{device_uuid, platform};device_secretis revealed once, and a PAK is issued. - Afterward: send
{device_uuid, platform, device_secret}to receive a new PAK. 401 on mismatch.
final resp = await http.post(
Uri.parse('$base/api/v1/devices'),
body: {'device_uuid': uuid, 'platform': 'ios'},
);
final json = jsonDecode(resp.body);
if (json['device_secret'] != null) {
await tokenStorage.saveDeviceSecret(json['device_secret']);
}
// later, refreshing the PAK
final secret = await tokenStorage.loadDeviceSecret(); // null → re-login via OAuth
await http.post(
Uri.parse('$base/api/v1/devices'),
body: {'device_uuid': uuid, 'platform': 'ios', 'device_secret': secret},
);❌ Do not store it in SharedPreferences.
Troubleshooting
invalid_client (release build)
🚨 Danger: The most common RP integration mistake
String.fromEnvironment()is a compile-time constant. If--dart-defineis missing, thedefaultValueis baked into the AOT build → the placeholder is sent as the client_id.
Pattern A (recommended): put the real client_id in defaultValue (a public PKCE client has no secret, so it is safe to commit):
static const String clientId = String.fromEnvironment(
'ONE_PASS_CLIENT_ID',
defaultValue: 'logi_<your_public_client_id>',
);Pattern B: a fail-fast guard:
class LogiConfig {
static const String _clientId = String.fromEnvironment(
'ONE_PASS_CLIENT_ID',
defaultValue: 'REPLACE_WITH_LOGI_CLIENT_ID',
);
static String get clientId {
if (_clientId.startsWith('REPLACE_WITH') || !_clientId.startsWith('logi_')) {
throw StateError(
'logi client_id not injected. Add `--dart-define=ONE_PASS_CLIENT_ID=logi_xxx`.',
);
}
return _clientId;
}
}The value is folded at compile time → it cannot be changed via runtime env. You must edit the Makefile/CI and rebuild.
Universal Link not intercepted
- The AASA Content-Type is not
application/json(common when served through a CDN) - The AASA has a BOM or extra bytes — check with
file -i apple-app-site-association /auth/logi/callbackis not inpaths, plus a reinstall- The iOS Simulator has poor AASA reliability → verify on a real device / TestFlight
invalid_grant: redirect_uri mismatch
The redirect_uri in the authorize and token calls must be a byte-exact match. Keep it in a single variable.
invalid_client: client credentials missing (public client)
client_type=public was not specified at registration → classified as confidential. Check /developer/applications/<id>; if it cannot be changed, register a new RP.
Custom scheme opens the browser on tap
The flutter_web_auth_2.CallbackActivity intent filter is missing from AndroidManifest. Add the block from the Android section above.
Works on the Simulator, fails on a real device
The iOS Simulator has limited UL interception. Use a real device / TestFlight. In CI, force the deep link with xcrun simctl openurl.
Review checklist
- [ ]
KeychainAccessibility.first_unlock_this_deviceinIOSOptions - [ ]
FlutterSecureStorage.xmlexcluded inbackup_rules.xml - [ ]
android:fullBackupContentin AndroidManifest - [ ] PKCE S256 + state verification
- [ ]
callbackUrlSchemematches theredirect_urischeme - [ ] biometric gated separately via
local_auth - [ ]
device_secretand refresh token stored separately - [ ] assert at app startup that the client_id starts with the
logi_prefix