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.
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 secure session storage
npm install capacitor-secure-storage-plugin
npx cap syncCreate 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.
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
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.
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
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.
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.
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.
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 { 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.
npm install @capacitor/app
npx cap syncThe App plugin handles both a cold launch and a link received while the app is open.
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.
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.
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();
}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 });