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.
npm install own-authAdd the database connection to the backend environment.
DATABASE_URL=postgres://user:password@localhost:5432/myappCreate the Own Auth tables.
npx own-auth migrateAdd the token pepper before creating the Own Auth instance.
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secretCreate the Own Auth instance used by the backend routes.
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 pub add http
flutter pub add flutter_secure_storageCreate 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.
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.
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,
});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.
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,
},
});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.
export const auth = createOwnAuth({
tokenPepper,
baseUrl: "https://app.example.com",
});The backend calls auth.requestMagicLink and returns the same confirmation for every email address.
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.comthroughapple-app-site-association - Android: associate
app.example.comthroughassetlinks.json - Route
/auth/magic-link/verifyinto the app on both platforms
Add app_links to receive the associated HTTPS route in Flutter.
flutter pub add app_linksapp_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.
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();
}
}extension MagicLinkAuthentication on AuthApi {
Future<AuthenticationResult> verifyMagicLink(String token) =>
_createSession('/auth/magic-link/verify', {'token': token});
}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.
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);
}
}
}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 });