Back to guides

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:

Magic link URL
https://yourapp.com/auth/magic-link/verify?token=abc123

Associate 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.

.env
APP_URL=https://yourapp.com
OWN_AUTH_TOKEN_PEPPER=replace-with-a-secret
OWN_AUTH_EMAIL_DELIVERY_KEY=oad_...
server/auth.ts
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.

server/request-magic-link.ts
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.

server/verify-mobile-magic-link.ts
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.

App.tsx
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.

magic_link_handler.dart
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.

src/app/app.component.ts
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();
}