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.

~20 minMediumflutter_appauth
Try OAuth Flow
Integration progress
0/16

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_id only — no secret)
  • A custom URL scheme reserved for your app (redirect URI)
  • Android minSdkVersion 18+ and iOS 12+

03Installation

Add flutter_appauth and (optionally) flutter_secure_storage for persisting tokens.

flutter pub add flutter_appauth flutter_secure_storage
After adding the plugin, run flutter 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.

lib/auth/nia_auth.dart
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',
];
There is no 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:/oauth2redirect
Anything compiled into the app binary — including these constants — is extractable by a determined attacker. That is expected for a public client; never put a client_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: openid email profile offline_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.

redirect URI
com.example.app:/oauth2redirect

Android — declare the scheme via manifestPlaceholders so the AppAuth plugin's redirect activity picks it up:

android/app/build.gradle
android {
    defaultConfig {
        manifestPlaceholders += [
            'appAuthRedirectScheme': 'com.example.app'
        ]
    }
}

iOS — declare the same scheme in Info.plist:

ios/Runner/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>
The scheme registered on the NIA client, the Android 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 & endpoints
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/logout

09Authorization 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.

lib/auth/login.dart
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,
    ),
  );
}
If sign-in is cancelled, 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.

lib/auth/login.dart
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.

lib/auth/verify.dart (optional)
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.
For most apps this manual step is unnecessary — AppAuth already talks to NIA over HTTPS and validates the token response. Add explicit JWKS verification only if you need to inspect claims before trusting the ID token, e.g. offline.

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.

lib/auth/storage.dart
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.

lib/auth/refresh.dart
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.

lib/auth/logout.dart
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 manifestPlaceholders and iOS CFBundleURLSchemes config 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
release build
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:/oauth2redirect

16Troubleshooting

  • redirect_uri_mismatch — the custom scheme must match exactly across the NIA client, Android manifestPlaceholders, iOS CFBundleURLSchemes, and the Dart redirectUrl constant.
  • Browser opens but never returns to the app — the redirect activity/scheme isn't registered natively; re-check android/app/build.gradle and Info.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 authorizeAndExchangeCode fresh 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.gradle and Info.plist are set, not just one.