Mobile
Sign in with NIA — Flutter
Add “Sign in with NIA” to a Flutter app with flutter_appauth, PKCE, and the system browser — no client secret required.
01Overview
This guide adds “Sign in with NIA” to a Flutter app using the standard OpenID Connect Authorization Code flow with PKCE. NIA is a conformant OIDC provider, so you use flutter_appauth — a wrapper around the native AppAuth SDKs for Android and iOS — with no NIA-specific SDK.
Flutter apps are native, public clients: there is no server to hold a client secret, so the app authenticates via PKCE only and the system browser (Custom Tabs / SFSafariViewController) handles the sign-in UI. flutter_appauth generates the PKCE verifier/challenge, launches the browser, and exchanges the code for tokens.
- Flow: Authorization Code + PKCE (S256), system-browser based
- Tokens: RS256 access & ID tokens, opaque refresh token
- You write: a login call, secure token storage, and a logout call
02Requirements
- Flutter 3.0+ and Dart 2.17+
- A NIA account with access to the client dashboard
- A registered public/native OAuth client (
client_idonly — no secret) - A custom URL scheme reserved for your app (redirect URI)
- Android
minSdkVersion 18+and iOS12+
03Installation
Add flutter_appauth and (optionally) flutter_secure_storage for persisting tokens.
flutter pub add flutter_appauth flutter_secure_storageflutter pub get and rebuild — native config changes (next step) require a full rebuild, not hot reload.04Required OAuth library
Create a single FlutterAppAuth instance plus your NIA constants. Reuse this instance for every auth call — login, refresh, and logout.
import 'package:flutter_appauth/flutter_appauth.dart';
final FlutterAppAuth appAuth = FlutterAppAuth();
const String issuer = "https://dev-api.niaauth.com";
const String clientId = "your-client-id";
const String redirectUrl = "com.example.app:/oauth2redirect";
const List<String> scopes = [
'openid',
'email',
'profile',
'offline_access',
];clientSecret — this is a public/native client. Never bundle a client secret inside a mobile app.05Environment Variables
A mobile app ships as a compiled binary, so there is no server-side .env to read at runtime. Keep the issuer, client ID, and redirect URL as Dart constants (above), or inject per-flavor values at build time with --dart-define.
flutter run \
--dart-define=NIA_ISSUER=https://dev-api.niaauth.com \
--dart-define=NIA_CLIENT_ID=your-client-id \
--dart-define=NIA_REDIRECT_URL=com.example.app:/oauth2redirectclient_secret here.06Register OAuth Client
In the NIA dashboard, create the client your Flutter app will authenticate as:
- Open Dashboard → Clients → New client
- Client type: Native / Public (
token_endpoint_auth_method = none, no secret issued) - Grant type: Authorization Code with PKCE required
- Allowed scopes:
openidemailprofileoffline_access - Add your redirect URI (next step)
- Copy the
client_id— public clients have no secret to copy
07Redirect URI
Native apps can't receive an HTTPS redirect directly, so NIA redirects back to your app via a custom URL scheme. Register this exact value on the NIA client, then configure the same scheme natively on both platforms.
com.example.app:/oauth2redirectAndroid — declare the scheme via manifestPlaceholders so the AppAuth plugin's redirect activity picks it up:
android {
defaultConfig {
manifestPlaceholders += [
'appAuthRedirectScheme': 'com.example.app'
]
}
}iOS — declare the same scheme in Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.example.app</string>
</array>
</dict>
</array>manifestPlaceholders value, the iOS CFBundleURLSchemes entry, and the redirectUrl constant in Dart must all match exactly.08Issuer
The issuer is the single URL AppAuth needs. Its discovery document advertises all endpoints below.
Issuer: https://dev-api.niaauth.com
Discovery: https://dev-api.niaauth.com/.well-known/openid-configuration
Authorization: https://dev-api.niaauth.com/oauth/authorize
Token: https://dev-api.niaauth.com/oauth/token
UserInfo: https://dev-api.niaauth.com/oauth/userinfo
JWKS: https://dev-api.niaauth.com/oauth/jwks.json
End session: https://dev-api.niaauth.com/oauth/logout09Authorization Flow
Launch the system browser to authenticate. flutter_appauth generates the PKCE verifier/challenge (S256), opens Custom Tabs / SFSafariViewController at issuer, and waits for the redirect back to your custom scheme.
import 'package:flutter_appauth/flutter_appauth.dart';
import 'nia_auth.dart';
Future<AuthorizationTokenResponse?> login() async {
return appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
clientId,
redirectUrl,
issuer: issuer,
scopes: scopes,
),
);
}authorizeAndExchangeCode throws a PlatformException — wrap the call in try/catch and handle user_cancelled gracefully.10Token Exchange
authorizeAndExchangeCode performs the whole round trip in one call: it opens the browser, receives the authorization code on redirect, and exchanges it for tokens using the PKCE verifier — no separate exchange step is needed.
final AuthorizationTokenResponse? result = await login();
if (result != null) {
final String accessToken = result.accessToken!;
final String? idToken = result.idToken;
final String? refreshToken = result.refreshToken;
final DateTime? accessTokenExpiry = result.accessTokenExpirationDateTime;
await storeTokens(
accessToken: accessToken,
idToken: idToken,
refreshToken: refreshToken,
expiry: accessTokenExpiry,
);
}11JWKS Validation
AppAuth validates the token response as part of the exchange (HTTPS to NIA's token endpoint, matching state and PKCE verifier). NIA signs ID tokens with RS256; if you additionally want to verify the id_token signature client-side, fetch NIA's public keys from the JWKS endpoint.
import 'package:http/http.dart' as http;
import 'dart:convert';
Future<Map<String, dynamic>> fetchJwks() async {
final res = await http.get(Uri.parse('https://dev-api.niaauth.com/oauth/jwks.json'));
return jsonDecode(res.body) as Map<String, dynamic>;
}
// Use a JWT library (e.g. jose_jwt, dart_jsonwebtoken) with the matching
// key (by "kid") to verify the RS256 signature, iss, aud, and exp claims.12Session Management
Persist tokens in flutter_secure_storage (Keychain on iOS, Keystore-backed EncryptedSharedPreferences on Android) — never in plain SharedPreferences. On app launch, check for a stored access token and its expiry to decide whether the user is signed in.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
final storage = FlutterSecureStorage();
Future<void> storeTokens({
required String accessToken,
String? idToken,
String? refreshToken,
DateTime? expiry,
}) async {
await storage.write(key: 'nia_access_token', value: accessToken);
if (idToken != null) await storage.write(key: 'nia_id_token', value: idToken);
if (refreshToken != null) await storage.write(key: 'nia_refresh_token', value: refreshToken);
if (expiry != null) await storage.write(key: 'nia_expiry', value: expiry.toIso8601String());
}
Future<bool> isSignedIn() async {
final token = await storage.read(key: 'nia_access_token');
return token != null;
}
Future<void> clearTokens() => storage.deleteAll();13Refresh Tokens
Because you requested offline_access, NIA returns a refresh token. Exchange it for a new access token once the current one is close to expiring. NIA rotates refresh tokens, so always overwrite the stored value.
import 'package:flutter_appauth/flutter_appauth.dart';
import 'nia_auth.dart';
import 'storage.dart';
Future<TokenResponse?> refreshSession() async {
final refreshToken = await storage.read(key: 'nia_refresh_token');
if (refreshToken == null) return null;
final TokenResponse? result = await appAuth.token(
TokenRequest(
clientId,
redirectUrl,
issuer: issuer,
refreshToken: refreshToken,
grantType: 'refresh_token',
),
);
if (result != null) {
await storeTokens(
accessToken: result.accessToken!,
idToken: result.idToken,
// NIA rotates the refresh token — persist the replacement, falling
// back to the old one only if none was returned.
refreshToken: result.refreshToken ?? refreshToken,
expiry: result.accessTokenExpirationDateTime,
);
}
return result;
}14Logout
Clear secure storage and end the NIA session so a subsequent login doesn't silently reuse the browser's cookie. Register your post_logout_redirect_uri (the same custom scheme) on the client.
import 'package:flutter_appauth/flutter_appauth.dart';
import 'nia_auth.dart';
import 'storage.dart';
Future<void> logout() async {
final idToken = await storage.read(key: 'nia_id_token');
await appAuth.endSession(
EndSessionRequest(
idTokenHint: idToken,
postLogoutRedirectUrl: redirectUrl,
issuer: issuer,
),
);
await clearTokens();
}15Deployment
Before going live:
- Register the production custom scheme (or a distinct prod client) on the NIA client
- Confirm this is a public/native client — no secret should ever ship in the app bundle
- Ship the Android
manifestPlaceholdersand iOSCFBundleURLSchemesconfig in your release build, not just debug - Use
flutter_secure_storage(or platform Keychain/Keystore) for tokens — never plain storage - Verify Android App Links / iOS Universal Links don't intercept the custom-scheme redirect on real devices
flutter build apk --release \
--dart-define=NIA_ISSUER=https://dev-api.niaauth.com \
--dart-define=NIA_CLIENT_ID=your-prod-client-id \
--dart-define=NIA_REDIRECT_URL=com.example.app:/oauth2redirect
flutter build ipa --release \
--dart-define=NIA_ISSUER=https://dev-api.niaauth.com \
--dart-define=NIA_CLIENT_ID=your-prod-client-id \
--dart-define=NIA_REDIRECT_URL=com.example.app:/oauth2redirect16Troubleshooting
- redirect_uri_mismatch — the custom scheme must match exactly across the NIA client, Android
manifestPlaceholders, iOSCFBundleURLSchemes, and the DartredirectUrlconstant. - Browser opens but never returns to the app — the redirect activity/scheme isn't registered natively; re-check
android/app/build.gradleandInfo.plist, then do a full rebuild (not hot reload). - invalid_client — the client was registered as confidential (expects a secret). It must be public/native with
token_endpoint_auth_method = none. - invalid_grant / PKCE failed — usually a stale or reused authorization code; always call
authorizeAndExchangeCodefresh per login attempt and don't retry a captured code. - invalid_scope — the scope isn't in the client's
allowed_scopes; add it in the dashboard. - Works on Android, fails on iOS (or vice versa) — the custom scheme config is platform-specific; confirm both
build.gradleandInfo.plistare set, not just one.