How to use Own Auth with Flutter
Own Auth can power authentication for a Flutter app without putting database access or authentication secrets on the device. Own Auth runs in your backend. The Flutter app calls that backend over HTTPS, stores only an opaque session token, and sends the token back when it needs protected data.
This guide builds email and password sign-in first, then adds session restoration, magic links, social sign-in, and sign-out. The examples use bearer sessions because they work cleanly when a native app and API use different origins.
How the integration works
Flutter app
-> your HTTPS auth API
-> own-auth on your backend
-> your Postgres database
Auth email
-> Own Auth email provider
-> HTTPS app link or hosted link
-> Flutter app
-> your backend verifies the one-time tokenPrerequisites
- A Flutter application targeting iOS, Android, or both
- A Node.js 18 or later backend, or a Cloudflare Worker with Node.js compatibility enabled
- A Postgres database
- HTTPS for the production API and auth links
- An iOS bundle ID and Android application ID for app-link configuration
Set up Own Auth in your backend
Install Own Auth and run its migrations in the backend project, not in the Flutter project.
npm install own-auth
npx own-auth migrateStore the database connection and a long random token pepper in the backend environment. The pepper must remain stable because changing it invalidates existing sessions and one-time tokens.
DATABASE_URL=postgres://user:password@localhost:5432/myapp
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secretCreate one shared Own Auth instance. baseUrl is where Own Auth creates application auth links.
import { createOwnAuth } from "own-auth";
export const auth = createOwnAuth({
tokenPepper: process.env.OWN_AUTH_TOKEN_PEPPER,
baseUrl: "https://app.example.com",
});Add the Flutter dependencies
Use the HTTP package for API calls, flutter_secure_storage for the session token, and app_links when the application needs to receive magic links. Pin versions through pubspec.lock and review each package's platform setup before release.
flutter pub add http
flutter pub add flutter_secure_storage
flutter pub add app_linksCreate a small Flutter auth client
Keep networking and session storage in one client so widgets do not need to know how tokens are persisted. The client below saves a session only after a successful response.
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?,
);
}
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<AuthUser> signIn(String email, String password) =>
_createSession('/auth/signin', {
'email': email,
'password': password,
});
Future<AuthUser> signUp(String name, String email, String password) =>
_createSession('/auth/signup', {
'name': name,
'email': email,
'password': password,
});
Future<AuthUser> _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>;
final token = json['sessionToken'] as String;
await storage.write(key: sessionKey, value: token);
return AuthUser.fromJson(json['user'] as Map<String, dynamic>);
}
}Add the backend sign-up and sign-in endpoints
Validate each request body before calling Own Auth. Return the user fields needed by the app and the opaque session token over HTTPS. Never include password hashes, internal account records, or database errors.
const result = await auth.signUpEmailPassword({
name: body.name,
email: body.email,
password: body.password,
});
return Response.json({
user: result.user,
sessionToken: result.sessionToken,
});const result = await auth.signInEmailPassword({
email: body.email,
password: body.password,
});
return Response.json({
user: result.user,
sessionToken: result.sessionToken,
});For sign-in, use the same safe invalid-credentials response whether the email is unknown or the password is wrong. Sign-up can explain that an email is already registered because the user must resolve that conflict.
Restore and verify the session
A token found on the device is not proof of a valid session. On startup, send it to a protected backend endpoint. The backend must ask Own Auth for the current session before returning user data.
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: current.user });Future<AuthUser?> restoreSession() async {
final token = await storage.read(key: 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: 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>);
}Call restoreSession before choosing the signed-in or signed-out navigation tree. Keep a loading state while it runs so the app does not briefly show the sign-in screen to an authenticated user.
Add magic-link sign-in
The Flutter app asks the backend to send a link. The backend calls requestMagicLink and returns the same confirmation for every email address. This prevents the endpoint from revealing which users exist.
await auth.requestMagicLink({ email: body.email });
return Response.json({
message: "If that address can sign in, a link is on its way.",
});Use an HTTPS Universal Link on iOS and Android App Link on Android whenever possible. Email clients handle HTTPS links more reliably than custom schemes. You can also use an Own Auth hosted link to bridge from go.own-auth.com to your app destination while preserving the one-time token.
- Host apple-app-site-association at https://app.example.com/.well-known/apple-app-site-association
- Host assetlinks.json at https://app.example.com/.well-known/assetlinks.json
- Associate the app.example.com domain with the iOS and Android applications
- Keep a browser fallback for users who do not have the app installed
When app_links receives the URL, extract only the expected token parameter and send it straight to the backend. Subscribe early so the application handles both a cold launch and a link received while it is already running.
import 'dart:async';
import 'package:app_links/app_links.dart';
bool isMagicLink(Uri uri) =>
(uri.scheme == 'https' &&
uri.host == 'app.example.com' &&
uri.path == '/auth/magic-link/verify') ||
(uri.scheme == 'myapp' &&
uri.host == 'auth' &&
uri.path == '/magic');
class MagicLinkHandler {
MagicLinkHandler(this.authApi, this.onSignedIn);
final AuthApi authApi;
final void Function(AuthUser user) onSignedIn;
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(_handleUri);
}
Future<void> _handleUri(Uri uri) async {
if (!isMagicLink(uri)) return;
final token = uri.queryParameters['token'];
if (token == null || token.isEmpty) return;
final user = await authApi.verifyMagicLink(token);
onSignedIn(user);
}
Future<void> dispose() async {
await subscription?.cancel();
}
}The Flutter method posts the one-time token and stores the new session returned by the backend.
Future<AuthUser> verifyMagicLink(String token) =>
_createSession('/auth/magic-link/verify', {'token': token});const result = await auth.verifyMagicLink({ token: body.token });
return Response.json({
user: result.user,
sessionToken: result.sessionToken,
});Add Apple or Google sign-in
A Flutter provider package can obtain an Apple identity token, Google ID token, or authorisation code. Send that credential to your backend. Do not decode its claims in Flutter and treat them as a verified identity.
final providerResult = await nativeGoogleSignIn();
await authApi.signInWithGoogle(
providerResult.idToken,
);The backend verifies the credential with the provider's supported server library. Check its signature, issuer, audience, expiry, and nonce where applicable. Pass only the verified identity to Own Auth.
const googleUser = await verifyGoogleIdToken(body.idToken);
const result = await auth.signInWithExternalProvider({
provider: "google",
providerAccountId: googleUser.sub,
email: googleUser.email,
emailVerified: googleUser.email_verified === true,
});
return Response.json({
user: result.user,
sessionToken: result.sessionToken,
});Sign out safely
Revoke the database session before deleting the device copy. Clear the local token in a finally block so the user is signed out on that device even when the network request fails.
Future<void> signOut() async {
final token = await storage.read(key: sessionKey);
try {
if (token != null) {
await client.post(
baseUrl.resolve('/auth/signout'),
headers: {'Authorization': 'Bearer $token'},
);
}
} finally {
await storage.delete(key: sessionKey);
}
}Production checklist
- Use HTTPS for the API, app links, and every authentication callback
- Keep Own Auth, database credentials, the token pepper, and provider secrets on the backend
- Store only the opaque session token in iOS Keychain or Android Keystore-backed storage
- Verify the Own Auth session on every protected backend request
- Verify Apple, Google, or other provider credentials on the backend before calling Own Auth
- Never log passwords, session tokens, provider credentials, magic-link URLs, or one-time tokens
- Use generic responses for invalid credentials and magic-link requests
- Test magic links when the app is closed, backgrounded, and already open
FAQ
Can I install Own Auth directly in Flutter?
No. Own Auth is the server-side authentication core. Install it in your backend and expose narrow HTTPS endpoints for the Flutter application.
Should Flutter use cookies or bearer tokens?
Either can work, but an opaque bearer token in OS-backed secure storage is usually simpler for a native app calling a separate API domain. If you use cookies, keep them Secure and HttpOnly and account for the platform HTTP client's cookie behavior.
Does a magic link create the session in Flutter?
No. Flutter receives the link and forwards its one-time token to your backend. Own Auth verifies and consumes the token on the backend, creates the database session, and returns an opaque session token to the app.
How should Flutter web store the session?
For Flutter web, prefer a Secure HttpOnly same-site cookie set by the backend instead of exposing a long-lived bearer token to browser JavaScript. The bearer-token examples in this guide are aimed at installed iOS and Android applications.
Conclusion
A Flutter integration with Own Auth stays deliberately small. Flutter owns forms, secure device storage, app links, and native provider UI. Your backend owns credential validation, provider verification, one-time token consumption, session creation, session revocation, and every trusted access decision.