Back to guides

Add Own Auth to a Flutter app


Own Auth runs in your backend. Flutter sends credentials over HTTPS, stores the returned session token with flutter_secure_storage, and presents that token to your API. Password login, session restoration, magic links, and sign-out use the same client on iOS and Android.

Set up Own Auth in your backend

Install Own Auth in the backend project.

Backend terminal
npm install own-auth

Add the database connection to the backend environment.

.env
DATABASE_URL=postgres://user:password@localhost:5432/myapp

Create the Own Auth tables.

Backend terminal
npx own-auth migrate

Add the token pepper before creating the Own Auth instance.

.env
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret

Create the Own Auth instance used by the backend routes.

auth.ts
import { createOwnAuth } from "own-auth";

const tokenPepper = process.env.OWN_AUTH_TOKEN_PEPPER!;

export const auth = createOwnAuth({ tokenPepper });

Add the Flutter dependencies

Add http and flutter_secure_storage for API requests and session storage.

Flutter terminal
flutter pub add http
flutter pub add flutter_secure_storage

Create the Flutter auth client

This client returns either a completed session or an MFA challenge. It saves the session token only after authentication is complete.

lib/auth/auth_api.dart
import 'dart:convert';

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;

class AuthUser {
  const AuthUser({required this.id, required this.email, this.name});

  final String id;
  final String email;
  final String? name;

  factory AuthUser.fromJson(Map<String, dynamic> json) => AuthUser(
        id: json['id'] as String,
        email: json['email'] as String,
        name: json['name'] as String?,
      );
}

sealed class AuthenticationResult {
  const AuthenticationResult();
}

class SessionCreated extends AuthenticationResult {
  const SessionCreated(this.user);

  final AuthUser user;
}

class MfaChallenge extends AuthenticationResult {
  const MfaChallenge({
    required this.challengeToken,
    required this.methods,
    required this.expiresAt,
  });

  final String challengeToken;
  final List<String> methods;
  final DateTime expiresAt;
}

class AuthApi {
  AuthApi({
    required this.baseUrl,
    http.Client? client,
    FlutterSecureStorage? storage,
  })  : client = client ?? http.Client(),
        storage = storage ?? const FlutterSecureStorage();

  static const sessionKey = 'own_auth_session';

  final Uri baseUrl;
  final http.Client client;
  final FlutterSecureStorage storage;

  Future<AuthenticationResult> signIn(String email, String password) =>
      _createSession('/auth/signin', {
        'email': email,
        'password': password,
      });

  Future<AuthenticationResult> signUp(
    String name,
    String email,
    String password,
  ) =>
      _createSession('/auth/signup', {
        'name': name,
        'email': email,
        'password': password,
      });

  Future<AuthenticationResult> _createSession(
    String path,
    Map<String, Object?> body,
  ) async {
    final response = await client.post(
      baseUrl.resolve(path),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(body),
    );

    if (response.statusCode < 200 || response.statusCode >= 300) {
      throw Exception('Authentication failed');
    }

    final json = jsonDecode(response.body) as Map<String, dynamic>;
    if (json['status'] == 'mfa_required') {
      return MfaChallenge(
        challengeToken: json['challengeToken'] as String,
        methods: (json['methods'] as List<dynamic>).cast<String>(),
        expiresAt: DateTime.parse(json['expiresAt'] as String),
      );
    }

    final token = json['sessionToken'] as String;
    await storage.write(key: sessionKey, value: token);
    return SessionCreated(
      AuthUser.fromJson(json['user'] as Map<String, dynamic>),
    );
  }
}

Add the backend sign-up and sign-in endpoints

These handlers call auth.signUpEmailPassword and auth.signInEmailPassword, then return the public user with the opaque session token.

signup-handler.ts
const result = await auth.signUpEmailPassword({
  name: body.name,
  email: body.email,
  password: body.password,
});

return Response.json({
  status: result.status,
  user: {
    id: result.user.id,
    email: result.user.email,
    name: result.user.name,
  },
  sessionToken: result.sessionToken,
});
signin-handler.ts
const result = await auth.signInEmailPassword({
  email: body.email,
  password: body.password,
});

if (result.status === "mfa_required") {
  return Response.json({
    status: result.status,
    challengeToken: result.challengeToken,
    methods: result.methods,
    expiresAt: result.expiresAt,
  });
}

return Response.json({
  status: result.status,
  user: {
    id: result.user.id,
    email: result.user.email,
    name: result.user.name,
  },
  sessionToken: result.sessionToken,
});

Restore the session

On launch, send the stored token to a backend endpoint that calls auth.getCurrentSession. A revoked or expired session returns 401 and is removed from the device.

current-session-handler.ts
const header = request.headers.get("Authorization");
const sessionToken = header?.startsWith("Bearer ")
  ? header.slice("Bearer ".length)
  : null;

const current = sessionToken
  ? await auth.getCurrentSession(sessionToken)
  : null;

if (!current) {
  return new Response("Unauthorized", { status: 401 });
}

return Response.json({
  user: {
    id: current.user.id,
    email: current.user.email,
    name: current.user.name,
  },
});
lib/auth/auth_api.dart
extension SessionRestoration on AuthApi {
  Future<AuthUser?> restoreSession() async {
    final token = await storage.read(key: AuthApi.sessionKey);
    if (token == null) return null;

    final response = await client.get(
      baseUrl.resolve('/auth/me'),
      headers: {'Authorization': 'Bearer $token'},
    );

    if (response.statusCode == 401) {
      await storage.delete(key: AuthApi.sessionKey);
      return null;
    }
    if (response.statusCode != 200) {
      throw Exception('Could not restore the session');
    }

    final json = jsonDecode(response.body) as Map<String, dynamic>;
    return AuthUser.fromJson(json['user'] as Map<String, dynamic>);
  }
}

Add magic-link sign-in

auth.requestMagicLink builds the verification URL from baseUrl. Add the HTTPS link domain to the existing auth config.

auth.ts
export const auth = createOwnAuth({
  tokenPepper,
  baseUrl: "https://app.example.com",
});

The backend calls auth.requestMagicLink and returns the same confirmation for every email address.

request-magic-link-handler.ts
await auth.requestMagicLink({ email: body.email });

return Response.json({
  message: "If that address can sign in, a link is on its way.",
});

Use the same HTTPS route as an iOS Universal Link and Android App Link.

  • iOS: associate app.example.com through apple-app-site-association
  • Android: associate app.example.com through assetlinks.json
  • Route /auth/magic-link/verify into the app on both platforms

Add app_links to receive the associated HTTPS route in Flutter.

Flutter terminal
flutter pub add app_links

app_links supplies the launch URL and links received while the app is open. Accept only the configured host and path before sending the token to the backend.

lib/auth/magic_link_handler.dart
import 'dart:async';

import 'package:app_links/app_links.dart';

import 'auth_api.dart';

bool isMagicLink(Uri uri) =>
    uri.scheme == 'https' &&
    uri.host == 'app.example.com' &&
    uri.path == '/auth/magic-link/verify';

class MagicLinkHandler {
  MagicLinkHandler(
    this.authApi,
    this.onSignedIn,
    this.onMfaRequired,
  );

  final AuthApi authApi;
  final void Function(AuthUser user) onSignedIn;
  final void Function(MfaChallenge challenge) onMfaRequired;
  final AppLinks appLinks = AppLinks();
  StreamSubscription<Uri>? subscription;

  Future<void> start() async {
    final initialUri = await appLinks.getInitialLink();
    if (initialUri != null) await _handleUri(initialUri);

    subscription = appLinks.uriLinkStream.listen(
      (uri) => unawaited(_handleUri(uri)),
    );
  }

  Future<void> _handleUri(Uri uri) async {
    if (!isMagicLink(uri)) return;

    final token = uri.queryParameters['token'];
    if (token == null || token.isEmpty) return;

    final result = await authApi.verifyMagicLink(token);
    if (result is SessionCreated) onSignedIn(result.user);
    if (result is MfaChallenge) onMfaRequired(result);
  }

  Future<void> dispose() async {
    await subscription?.cancel();
  }
}
lib/auth/auth_api.dart
extension MagicLinkAuthentication on AuthApi {
  Future<AuthenticationResult> verifyMagicLink(String token) =>
      _createSession('/auth/magic-link/verify', {'token': token});
}
verify-magic-link-handler.ts
const result = await auth.verifyMagicLink({ token: body.token });

if (result.status === "mfa_required") {
  return Response.json({
    status: result.status,
    challengeToken: result.challengeToken,
    methods: result.methods,
    expiresAt: result.expiresAt,
  });
}

return Response.json({
  status: result.status,
  user: {
    id: result.user.id,
    email: result.user.email,
    name: result.user.name,
  },
  sessionToken: result.sessionToken,
});

Sign out

Ask the backend to revoke the session, then remove the device copy even when the request fails.

lib/auth/auth_api.dart
extension SessionSignOut on AuthApi {
  Future<void> signOut() async {
    final token = await storage.read(key: AuthApi.sessionKey);

    try {
      if (token != null) {
        await client.post(
          baseUrl.resolve('/auth/signout'),
          headers: {'Authorization': 'Bearer $token'},
        );
      }
    } finally {
      await storage.delete(key: AuthApi.sessionKey);
    }
  }
}
signout-handler.ts
const header = request.headers.get("Authorization");
if (!header?.startsWith("Bearer ")) {
  return new Response("Unauthorized", { status: 401 });
}

await auth.signOut(header.slice("Bearer ".length));
return new Response(null, { status: 204 });