Flutter Integration
Flutter communicates with your node-auth backend via HTTP. As a native client, Flutter must use bearer-token mode — add the X-Auth-Strategy: bearer header to all token-issuing requests so the server returns tokens in the JSON body instead of setting cookies.
Dependencies
dependencies:
http: ^1.2.0
flutter_secure_storage: ^9.0.0
flutter_web_auth_2: ^3.0.0 # for OAuth flows
flutter pub get
AuthService
A complete, copy-paste-ready AuthService is available in the repository:
examples/flutter-integration.example.dart
It covers: login, logout, token refresh, TOTP 2FA, SMS OTP, magic links, OAuth, change password, change email, email verification, account linking, and admin API calls.
Below is a condensed version of the core patterns.
Step 1 — Configuration & Token Storage
// lib/auth/auth_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const String kBaseUrl = 'https://your-api.example.com';
class AuthService {
AuthService._();
static final AuthService instance = AuthService._();
final _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
Future<String?> get accessToken => _storage.read(key: 'accessToken');
Future<String?> get refreshToken => _storage.read(key: 'refreshToken');
Future<void> _saveTokens(String access, String refresh) async {
await _storage.write(key: 'accessToken', value: access);
await _storage.write(key: 'refreshToken', value: refresh);
}
Future<void> clearTokens() async {
await _storage.delete(key: 'accessToken');
await _storage.delete(key: 'refreshToken');
}
/// Base headers for token-issuing requests.
/// `X-Auth-Strategy: bearer` tells node-auth to return tokens in the
/// JSON body instead of setting HttpOnly cookies (which Flutter cannot use).
Map<String, String> get _bearerHeaders => {
'Content-Type': 'application/json',
'X-Auth-Strategy': 'bearer',
};
/// Headers for authenticated requests (after login).
Future<Map<String, String>> _authHeaders() async {
final token = await accessToken;
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
};
}
Step 2 — Login and Token Refresh
// ---- Login ---------------------------------------------------------------
/// POST /auth/login — returns body including tempToken if 2FA is required.
Future<Map<String, dynamic>> login({
required String email,
required String password,
}) async {
final res = await http.post(
Uri.parse('$kBaseUrl/auth/login'),
headers: _bearerHeaders, // ← X-Auth-Strategy: bearer is essential
body: jsonEncode({'email': email, 'password': password}),
);
final body = jsonDecode(res.body) as Map<String, dynamic>;
if (res.statusCode == 200 && body['accessToken'] != null) {
await _saveTokens(
body['accessToken'] as String,
body['refreshToken'] as String,
);
}
return body;
}
// ---- Token refresh -------------------------------------------------------
/// POST /auth/refresh — must include X-Auth-Strategy: bearer and the
/// stored refreshToken in the body to receive new tokens.
Future<bool> refreshTokens() async {
final rt = await refreshToken;
if (rt == null) return false;
final res = await http.post(
Uri.parse('$kBaseUrl/auth/refresh'),
headers: _bearerHeaders,
body: jsonEncode({'refreshToken': rt}),
);
if (res.statusCode == 200) {
final body = jsonDecode(res.body) as Map<String, dynamic>;
await _saveTokens(
body['accessToken'] as String,
body['refreshToken'] as String,
);
return true;
}
return false;
}
// ---- Logout --------------------------------------------------------------
Future<void> logout() async {
final headers = await _authHeaders();
await http.post(Uri.parse('$kBaseUrl/auth/logout'), headers: headers);
await clearTokens();
}
// ---- Automatic 401 retry -------------------------------------------------
Future<http.Response> _authedRequest(
Future<http.Response> Function(Map<String, String> headers) fn,
) async {
final headers = await _authHeaders();
final res = await fn(headers);
if (res.statusCode == 401) {
if (await refreshTokens()) {
final newHeaders = await _authHeaders();
return fn(newHeaders);
}
}
return res;
}
// ---- Profile -------------------------------------------------------------
Future<Map<String, dynamic>> getProfile() async {
final res = await _authedRequest(
(h) => http.get(Uri.parse('$kBaseUrl/auth/me'), headers: h),
);
return jsonDecode(res.body) as Map<String, dynamic>;
}
Step 3 — TOTP 2FA
/// POST /auth/2fa/verify — call after login returns `tempToken` + `available2faMethods`.
Future<Map<String, dynamic>> verifyTotp({
required String tempToken,
required String totpCode,
}) async {
final res = await http.post(
Uri.parse('$kBaseUrl/auth/2fa/verify'),
headers: _bearerHeaders,
body: jsonEncode({'tempToken': tempToken, 'totpCode': totpCode}),
);
final body = jsonDecode(res.body) as Map<String, dynamic>;
if (res.statusCode == 200 && body['accessToken'] != null) {
await _saveTokens(
body['accessToken'] as String,
body['refreshToken'] as String,
);
}
return body;
}
Step 4 — Login Screen
Future<void> _login() async {
final result = await AuthService.instance.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
if (result['accessToken'] != null) {
// Direct login — tokens stored, navigate to dashboard
Navigator.pushReplacementNamed(context, '/dashboard');
} else if (result['requiresTwoFactor'] == true) {
// 2FA required — show code input
final tempToken = result['tempToken'] as String;
final methods = List<String>.from(result['available2faMethods'] as List);
Navigator.push(context, MaterialPageRoute(
builder: (_) => TwoFactorPage(tempToken: tempToken, methods: methods),
));
} else {
setState(() => _error = result['error'] as String? ?? 'Login failed');
}
}
OAuth / Social Login in Flutter
OAuth uses a browser redirect flow. Open the provider URL in the system browser using flutter_web_auth_2:
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
Future<void> loginWithOAuth(String provider) async {
final callbackScheme = 'myapp';
final resultUrl = await FlutterWebAuth2.authenticate(
url: '$kBaseUrl/auth/oauth/$provider',
callbackUrlScheme: callbackScheme,
);
final uri = Uri.parse(resultUrl);
if (uri.path.contains('/auth/2fa')) {
// 2FA required after OAuth — use tempToken for bearer-mode 2FA verify
final tempToken = uri.queryParameters['tempToken']!;
final methods = (uri.queryParameters['methods'] ?? '').split(',');
// Navigate to TwoFactorPage
}
// If no 2FA, the user is authenticated; call /auth/me to get profile
}
Register myapp as a custom URL scheme:
- Android:
AndroidManifest.xml— add theCallbackActivityand intent-filter fromflutter_web_auth_2docs. - iOS:
Info.plist— addCFBundleURLSchemes: [myapp].
Set siteUrl: 'myapp://auth' in your server's AuthConfig.email so the OAuth callback redirects back to the app.
Security Notes
- ✅ Tokens in flutter_secure_storage (Keychain on iOS, EncryptedSharedPreferences on Android)
- ✅
X-Auth-Strategy: beareron all token-issuing requests — never rely on cookies - ✅ Auto-refresh on 401 with a single retry (
_authedRequest) - ✅ HTTPS always in production