Updated 8 August 2026
Open magic links in mobile apps
Mobile magic-link authentication should start with an HTTPS URL. Universal Links on iOS and App Links on Android deliver that URL to the installed app, while the same address remains available as a browser fallback. Apps without a web route can use an Own Auth hosted link as the HTTPS bridge to a configured app destination.
Use an HTTPS link
Custom schemes such as myapp:// are not handled consistently by email clients and embedded browsers. With baseUrl set to the application's HTTPS origin, Own Auth creates a link in this form:
https://yourapp.com/auth/magic-link/verify?token=abc123Associate the domain with the app
Publish the platform association files on the same host used by the magic link.
- iOS: host an apple-app-site-association file at
https://yourapp.com/.well-known/apple-app-site-association - Android: host an assetlinks.json file at
https://yourapp.com/.well-known/assetlinks.json
Keep the browser fallback working
Serve the same /auth/magic-link/verify path on the website. If the app is not installed or the platform association fails, the browser can submit the token to the backend verification endpoint and complete the web sign-in. Do not render the token into the page, persist it, or send it to analytics and logs.
Configure managed delivery
Configure OwnAuthManagedEmailProvider in the backend's existing Own Auth module. Own Auth creates the token, stores its protected hash, builds the HTTPS URL from baseUrl, and passes the finished message to Delivery. Keep the delivery key and token pepper on the server.
APP_URL=https://yourapp.com
OWN_AUTH_TOKEN_PEPPER=replace-with-a-secret
OWN_AUTH_EMAIL_DELIVERY_KEY=oad_...import {
createOwnAuth,
OwnAuthManagedEmailProvider,
} from "own-auth";
export const auth = createOwnAuth({
tokenPepper: process.env.OWN_AUTH_TOKEN_PEPPER!,
baseUrl: process.env.APP_URL!,
emailProvider: new OwnAuthManagedEmailProvider({
deliveryKey: process.env.OWN_AUTH_EMAIL_DELIVERY_KEY!,
}),
});Request the magic link
Call auth.requestMagicLink after the backend validates the submitted email address. The application does not construct a token URL or call the Delivery API directly.
import { auth } from "./auth";
await auth.requestMagicLink({
email: "user@example.com",
});Each listener below accepts an onToken callback. Pass the app's existing method that posts the token to a backend route calling auth.verifyMagicLink.
Verify the token in the backend
The mobile app reads the token only to send it to the application backend. The backend consumes it once with auth.verifyMagicLink, returns an MFA challenge when required, and selects only the user and session fields the app needs.
import { auth } from "./auth";
export async function verifyMobileMagicLink(token: string) {
const result = await auth.verifyMagicLink({ token });
if (result.status === "mfa_required") {
return {
status: result.status,
challengeToken: result.challengeToken,
methods: result.methods,
expiresAt: result.expiresAt,
};
}
return {
status: result.status,
user: {
id: result.user.id,
email: result.user.email,
name: result.user.name,
imageUrl: result.user.imageUrl,
},
sessionToken: result.sessionToken,
sessionExpiresAt: result.session.expiresAt,
};
}React Native example
React Native's Linking API returns the launch URL and links opened while the app is running.
import { Linking } from "react-native";
type TokenHandler = (token: string) => void | Promise<void>;
export async function startMagicLinkListener(onToken: TokenHandler) {
const subscription = Linking.addEventListener("url", ({ url }) => {
void handleMagicLink(url, onToken);
});
const initialUrl = await Linking.getInitialURL();
if (initialUrl) await handleMagicLink(initialUrl, onToken);
return () => subscription.remove();
}
async function handleMagicLink(url: string, onToken: TokenHandler) {
const parsed = new URL(url);
if (
parsed.protocol !== "https:" ||
parsed.host !== "yourapp.com" ||
parsed.pathname !== "/auth/magic-link/verify"
) return;
const token = parsed.searchParams.get("token");
if (token) await onToken(token);
}Flutter example
Flutter's app_links package returns the launch URL and links opened while the app is running.
import 'dart:async';
import 'package:app_links/app_links.dart';
final appLinks = AppLinks();
StreamSubscription<Uri>? linkSubscription;
typedef TokenHandler = Future<void> Function(String token);
Future<void> startMagicLinkListener(TokenHandler onToken) async {
final initialUri = await appLinks.getInitialLink();
if (initialUri != null) await handleMagicLink(initialUri, onToken);
linkSubscription = appLinks.uriLinkStream.listen(
(uri) => unawaited(handleMagicLink(uri, onToken)),
);
}
Future<void> handleMagicLink(Uri uri, TokenHandler onToken) async {
if (uri.scheme != 'https' ||
uri.host != 'yourapp.com' ||
uri.path != '/auth/magic-link/verify') return;
final token = uri.queryParameters['token'];
if (token != null) await onToken(token);
}Ionic and Capacitor example
Capacitor's App plugin returns the launch URL and emits appUrlOpen while the app is running.
import { App } from "@capacitor/app";
type TokenHandler = (token: string) => void | Promise<void>;
async function handleMagicLink(url: string, onToken: TokenHandler) {
const parsed = new URL(url);
if (
parsed.protocol !== "https:" ||
parsed.host !== "yourapp.com" ||
parsed.pathname !== "/auth/magic-link/verify"
) return;
const token = parsed.searchParams.get("token");
if (token) await onToken(token);
}
export async function startMagicLinkListener(onToken: TokenHandler) {
const launch = await App.getLaunchUrl();
if (launch?.url) await handleMagicLink(launch.url, onToken);
const listener = await App.addListener("appUrlOpen", ({ url }) => {
void handleMagicLink(url, onToken);
});
return () => listener.remove();
}