Framework guide

Flutter & Dart

Set up Neuctra Authix in a Flutter app: one client, sessions that survive a restart, an auth gate for protected screens, and paged data in a ListView.

Flutter 3Dart 3iOS · Android · Web · Desktop

Only publishable keys ship in an app

A binary can be decompiled and --dart-define values are compiled in as plain text, so treat anything you pass that way as public. Use pk_live_…. The SDK throws at construction if you pass a secret key as publishableKey, but only if you do not work around it.

  1. Install

    Bash
    flutter pub add neuctra_authix_dart_package flutter_secure_storage
    
    # or add them by hand
    #   neuctra_authix_dart_package: ^2.0.0
    #   flutter_secure_storage: ^9.0.0
  2. Pass your keys at build time

    Dart has no runtime .env, so credentials are compiled in with --dart-define.

    Bash
    flutter run \
      --dart-define=AUTHIX_APP_ID=app_xxxxxxxxxxxx \
      --dart-define=AUTHIX_PUBLISHABLE_KEY=pk_live_xxxxxxxx_xxxxxxxxxxxxxxxx
    
    # Or keep them in a file and pass it once
    flutter run --dart-define-from-file=authix.json
  3. Create the client and persist the session

    In a browser the user agent stores the session cookie automatically. Dart has no cookie jar, so the SDK ships one — and it lives in memory, which means you decide where it is saved.

    Dart
    1// lib/services/authix.dart
    2import 'package:flutter_secure_storage/flutter_secure_storage.dart';
    3import 'package:neuctra_authix_dart_package/neuctra_authix_dart_package.dart';
    4
    5/// One client for the whole app. Each client keeps its own cookie jar, so
    6/// creating them per screen means signing in on one leaves the rest signed out.
    7final authix = NeuctraAuthix(
    8  const NeuctraAuthixConfig(
    9    appId: String.fromEnvironment('AUTHIX_APP_ID'),
    10    publishableKey: String.fromEnvironment('AUTHIX_PUBLISHABLE_KEY'),
    11    appName: 'MyApp',
    12  ),
    13);
    14
    15const _storage = FlutterSecureStorage();
    16const _sessionKey = 'authix_session';
    17
    18/// The cookie jar lives in memory, so it must be written somewhere durable
    19/// after every sign-in.
    20Future<void> saveSession() async {
    21  final cookie = authix.session.sessionCookie;
    22  if (cookie != null) {
    23    await _storage.write(key: _sessionKey, value: cookie);
    24  }
    25}
    26
    27Future<void> restoreSession() async {
    28  final saved = await _storage.read(key: _sessionKey);
    29  if (saved != null) authix.session.setSessionCookie(saved);
    30}
    31
    32Future<void> forgetSession() => _storage.delete(key: _sessionKey);

    Why secure storage

    That cookie is a bearer token: whoever holds it is that user until it expires. SharedPreferences is not an appropriate home for it.

  4. Hold one piece of auth state

    Dart
    1// lib/services/auth_state.dart
    2import 'package:flutter/foundation.dart';
    3
    4/// One piece of state the whole UI follows. Swapping it re-renders the gate.
    5final loggedIn = ValueNotifier<bool>(false);
  5. Decide the first screen at startup

    Dart
    1// lib/main.dart
    2import 'package:flutter/material.dart';
    3
    4import 'services/authix.dart';
    5import 'services/auth_state.dart';
    6import 'screens/auth_gate.dart';
    7
    8Future<void> main() async {
    9  WidgetsFlutterBinding.ensureInitialized();
    10
    11  await restoreSession();                        // put the saved cookie back
    12
    13  // Never throws: offline, expired or revoked all resolve to false.
    14  final session = await authix.checkUserSession();
    15  loggedIn.value = session.authenticated;
    16
    17  runApp(const MyApp());
    18}
    19
    20class MyApp extends StatelessWidget {
    21  const MyApp({super.key});
    22
    23  
    24  Widget build(BuildContext context) {
    25    return const MaterialApp(home: AuthGate());
    26  }
    27}

    Do not use hasSession to decide this

    session.hasSession only reports that a cookie exists locally — not that it is still valid. On Flutter Web it is always false, because the browser holds the cookie. checkUserSession() is the only answer to trust.

  6. Gate your screens

    Dart
    1// lib/screens/auth_gate.dart
    2import 'package:flutter/material.dart';
    3
    4import '../services/auth_state.dart';
    5import 'home_screen.dart';
    6import 'login_screen.dart';
    7
    8class AuthGate extends StatelessWidget {
    9  const AuthGate({super.key});
    10
    11  
    12  Widget build(BuildContext context) {
    13    return ValueListenableBuilder<bool>(
    14      valueListenable: loggedIn,
    15      builder: (context, isLoggedIn, _) =>
    16          isLoggedIn ? const HomeScreen() : const LoginScreen(),
    17    );
    18  }
    19}

    Sign-in now ends with loggedIn.value = true and the app moves itself — no manual navigation, and no route left behind that a back gesture can return to.

Signing in

Dart
1// lib/screens/login_screen.dart — the submit handler
2Future<void> signIn(String email, String password) async {
3  try {
4    await authix.loginUser(LoginParams(email: email, password: password));
5    await saveSession();
6    loggedIn.value = true;                       // the gate swaps screens
7  } on AuthenticationError {
8    showError('Wrong email or password');
9  } on NetworkError {
10    showError('Could not reach the server');
11  }
12}

Signing up and verifying

Signup issues a session immediately, but the account still needs verifying before it can store anything.

Dart
1Future<void> signUp(String name, String email, String password) async {
2  await authix.signupUser(
3    SignupParams(name: name, email: email, password: password),
4  );
5
6  // Signup signs the user in already — the server issues the cookie.
7  await saveSession();
8
9  // But the account is not verified, and an unverified account cannot store
10  // records. Send the code straight away rather than failing on the first write.
11  final session = await authix.checkUserSession();
12  await authix.requestEmailVerificationOTP(
13    userId: session.userId!,
14    email: email,
15  );
16}
17
18Future<void> confirmCode(String email, String otp) async {
19  await authix.verifyEmail(email: email, otp: otp);
20  loggedIn.value = true;
21}

Signing out

Dart
1Future<void> signOut() async {
2  try {
3    await authix.logoutUser();
4  } catch (_) {
5    // Ignore — the local session is cleared either way, so a logout on a bad
6    // connection cannot leave a usable session on the device.
7  }
8  await forgetSession();
9  loggedIn.value = false;
10}

Paged data in a ListView

Reads are bounded — 20 records by default, 100 maximum — so a list fetches the next page as the reader reaches the end.

Dart
1// lib/screens/notes_screen.dart
2class NotesScreen extends StatefulWidget {
3  const NotesScreen({super.key, required this.userId});
4  final String userId;
5
6  
7  State<NotesScreen> createState() => _NotesScreenState();
8}
9
10class _NotesScreenState extends State<NotesScreen> {
11  final _notes = <DataItem>[];
12  String? _cursor;
13  bool _hasMore = true;
14  bool _loading = false;
15
16  Future<void> _load() async {
17    if (_loading || !_hasMore) return;
18    setState(() => _loading = true);
19
20    final page = await authix.getUserData<DataItem>(
21      GetUserDataParams(
22        userId: widget.userId,
23        category: 'notes',
24        limit: 20,
25        cursor: _cursor,
26      ),
27      itemParser: DataItem.fromJson,
28    );
29
30    setState(() {
31      _notes.addAll(page);          // a Page is an Iterable of its records
32      _cursor = page.nextCursor;
33      _hasMore = page.hasMore;
34      _loading = false;
35    });
36  }
37
38  
39  void initState() {
40    super.initState();
41    _load();
42  }
43
44  
45  Widget build(BuildContext context) {
46    return ListView.builder(
47      itemCount: _notes.length + (_hasMore ? 1 : 0),
48      itemBuilder: (context, index) {
49        if (index == _notes.length) {
50          _load();                  // reached the end — fetch the next page
51          return const Center(child: CircularProgressIndicator());
52        }
53
54        final note = _notes[index];
55        return ListTile(
56          title: Text(note['title'] as String? ?? ''),
57          subtitle: Text('v${note.version}'),
58        );
59      },
60    );
61  }
62}

For a simple screen, the iterate* methods hand you a Stream and follow the cursor themselves:

Dart
1// Or let the SDK follow the cursor and stream records as they arrive.
2StreamBuilder<DataItem>(
3  stream: authix.iterateUserData<DataItem>(
4    GetUserDataParams(userId: userId, category: 'notes'),
5    itemParser: DataItem.fromJson,
6    maxPages: 20,
7  ),
8  builder: (context, snapshot) {
9    // ... accumulate snapshot.data as it arrives
10  },
11);

When a session dies mid-use

Tokens expire and password changes revoke every session, so a call can fail while the gate still says “signed in”.

Dart
1/// Tokens expire, and changing a password revokes every session for that user.
2/// So a call can fail even when the gate says "signed in". Handle it once.
3Future<T> guarded<T>(Future<T> Function() call) async {
4  try {
5    return await call();
6  } on NoUserSessionError {
7    await forgetSession();
8    loggedIn.value = false;        // the gate returns to the login screen
9    rethrow;
10  }
11}
12
13// use it
14final profile = await guarded(
15  () => authix.getUserProfile(userId: currentUserId),
16);

Flutter Web

One extra line, because the browser owns cookies there.

Dart
1import 'package:http/browser_client.dart';
2
3// On Flutter Web the browser owns the cookie jar and JavaScript cannot read an
4// HttpOnly cookie. Hand the client a BrowserClient and skip persistence.
5final authix = NeuctraAuthix(
6  config,
7  client: BrowserClient()..withCredentials = true,
8);

CORS on web

Credentialed cross-origin requests require an explicit allowed origin — a wildcard is rejected by the browser. Add your web origin to the app's allowed origins in the dashboard.

Project structure

Where each piece lands in a typical Flutter project.

Bash
lib/
├── main.dart                    # restore session, then decide the first screen
├── services/
│   ├── authix.dart              # one client + secure-storage persistence
│   └── auth_state.dart          # ValueNotifier<bool> loggedIn
└── screens/
    ├── auth_gate.dart           # picks Home or Login
    ├── login_screen.dart
    ├── signup_screen.dart       # signup + OTP verification
    ├── home_screen.dart
    └── notes_screen.dart        # paged reads

What the gate is and is not

The server owns access

The auth gate decides what renders. It is not what keeps one user out of another user's data — a patched binary could show HomeScreen without signing in, and every call it made would still be rejected, because requests carrying a publishable key are pinned to the signed-in session on the server.

Common mistakes

  • Creating a client per screen. Each gets its own cookie jar, so sessions stop being shared.
  • Forgetting to save the cookie after login. The jar is in memory; the user is signed out on next launch.
  • Deciding auth from hasSession. Use checkUserSession().
  • Skipping email verification. Writes fail with PermissionDeniedError until the account is verified.
  • Shipping a secret key. A --dart-define is not a secret store.

You now have

  • Signup with verification, login and logout.
  • A session that survives an app restart.
  • Screens gated on real auth state.
  • A paged list that will not load a whole dataset into memory.

Related