Back to guides

Add Own Auth to an Ionic and Capacitor app


Own Auth runs in your backend. The Ionic app sends credentials over HTTPS, stores the returned session token with capacitor-secure-storage-plugin, and presents it to your API. Password login, session checks, Capacitor app links, and sign-out use the same TypeScript client.

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 secure session storage

Ionic terminal
npm install capacitor-secure-storage-plugin
npx cap sync

Create the mobile auth client

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

auth-api.ts
import { SecureStoragePlugin } from "capacitor-secure-storage-plugin";

const API_URL = "https://api.example.com";
const SESSION_KEY = "own_auth_session";

type PublicUser = {
  id: string;
  email: string;
  name: string | null;
};

type SessionResponse = {
  status: "complete";
  user: PublicUser;
  sessionToken: string;
};

export type MfaChallenge = {
  status: "mfa_required";
  challengeToken: string;
  methods: Array<"totp" | "recovery_code" | "passkey">;
  expiresAt: string;
};

type AuthResponse = SessionResponse | MfaChallenge;

export const sessionStore = {
  async set(token: string) {
    await SecureStoragePlugin.set({ key: SESSION_KEY, value: token });
  },
  async get() {
    try {
      return (await SecureStoragePlugin.get({ key: SESSION_KEY })).value;
    } catch {
      return null;
    }
  },
  async remove() {
    try {
      await SecureStoragePlugin.remove({ key: SESSION_KEY });
    } catch {
      return;
    }
  },
};

export async function authRequest(
  path: string,
  body: Record<string, unknown>,
) {
  const response = await fetch(`${API_URL}${path}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });

  if (!response.ok) throw new Error("Authentication failed");

  const result = (await response.json()) as AuthResponse;
  if (result.status === "complete") {
    await sessionStore.set(result.sessionToken);
  }
  return result;
}

Email and password authentication

Password sign-up and sign-in are HTTPS requests to your backend. They do not need a Capacitor plugin.

Sign up

signup.ts
import { authRequest } from "./auth-api";

await authRequest("/auth/signup", {
  name: "Ada Lovelace",
  email: "ada@example.com",
  password: "a-long-unique-password",
});

The sign-up endpoint calls auth.signUpEmailPassword and returns the user with the session token.

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

return {
  status: result.status,
  user: {
    id: result.user.id,
    email: result.user.email,
    name: result.user.name,
  },
  sessionToken: result.sessionToken,
};

Sign in

signin.ts
import { authRequest } from "./auth-api";

await authRequest("/auth/signin", {
  email: "ada@example.com",
  password: "a-long-unique-password",
});

The sign-in endpoint calls auth.signInEmailPassword and handles an MFA challenge before reading the session result.

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,
});

Verify sessions on protected endpoints

Read the bearer token on protected backend requests and resolve it with auth.getCurrentSession.

require-session.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 });
}

Magic links in a Capacitor app

auth.requestMagicLink builds the verification URL from baseUrl. Add the HTTPS app-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.ts
await auth.requestMagicLink({ email: body.email });

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

Add Capacitor's App plugin, then configure https://app.example.com/auth/magic-link/verify as an iOS Universal Link and Android App Link.

Ionic terminal
npm install @capacitor/app
npx cap sync

The App plugin handles both a cold launch and a link received while the app is open.

mobile-links.ts
import { App } from "@capacitor/app";
import {
  authRequest,
  type MfaChallenge,
} from "./auth-api";

async function handleAuthLink(
  onMfaRequired: (challenge: MfaChallenge) => void,
  value?: string,
) {
  if (!value) return;

  const url = new URL(value);
  if (
    url.protocol !== "https:" ||
    url.host !== "app.example.com" ||
    url.pathname !== "/auth/magic-link/verify"
  ) return;

  const token = url.searchParams.get("token");
  if (!token) return;

  const result = await authRequest("/auth/magic-link/verify", { token });
  if (result.status === "mfa_required") onMfaRequired(result);
}

export async function startMagicLinkListener(
  onMfaRequired: (challenge: MfaChallenge) => void,
) {
  const launch = await App.getLaunchUrl();
  await handleAuthLink(onMfaRequired, launch?.url);

  const listener = await App.addListener("appUrlOpen", ({ url }) => {
    void handleAuthLink(onMfaRequired, url);
  });

  return () => listener.remove();
}

The app sends the token to the backend. auth.verifyMagicLink consumes it and returns the new session.

verify-magic-link.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.

signout.ts
import { sessionStore } from "./auth-api";

const sessionToken = await sessionStore.get();

try {
  if (sessionToken) {
    await fetch("https://api.example.com/auth/signout", {
      method: "POST",
      headers: { Authorization: `Bearer ${sessionToken}` },
    });
  }
} finally {
  await sessionStore.remove();
}
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 });