Engineering ~ 5 min read

OIDC with Capacitor

How to make oidc-client-ts authentication work with Capacitor

If you are building authentication in your app with OpenID Connect (OIDC) and like to keep things somewhat generic, you might have come across the oidc-client-ts library. It is a popular choice for handling OIDC authentication flows in SPAs and provides a lot of functionality out of the box.

However, when trying to port this to a mobile app using Capacitor, you might run into some issues. The main problem arises from the fact that Capacitor apps run in a WebView, which has different security and navigation constraints compared to regular web browsers. For instance, you don’t just redirect back to your application using your original URI, because in the WebView your app actually runs under a different scheme (like capacitor://localhost or http://localhost). If you tried to redirect back to http://localhost, the WebView would simply tell you the URL cannot be reached.

To make it work, you have to use a combination of Capacitor’s Browser API to open the authentication URL in the system browser and deep linking to handle the redirect back to your app.

Basic Requirements

First of all, you need to add these allowed origins to your OIDC provider’s configuration. Without this, the provider will block the authentication attempt.

  1. capacitor://localhost for iOS
  2. http://localhost for Android

Then make sure to install the required Capacitor plugins:

Terminal window
npm install @capacitor/app @capacitor/browser capacitor-secure-storage-plugin

Also, make sure to set up deep linking in your Capacitor app. You can follow the official Capacitor documentation for this. Here’s a brief overview:

You need to define your URL for deep linking. In production, you should use verified Universal Links or App Links (as stated in the Capacitor docs), but for testing purposes you can also use a custom scheme like myapp://app/callback.

For Android, you need to add an intent filter to your AndroidManifest.xml:

<activity ...>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="app" />
</intent-filter>
</activity>

For iOS, you need to add a URL type in your Info.plist:

<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.getcapacitor.capacitor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>

Then register those redirect URIs in your OIDC provider as well: myapp://app/callback

Modifying oidc-client-ts

First of all, I’m setting the redirect_uri to the deep link URI depending on the platform:

const redirectUri = Capacitor.isNativePlatform()
? `${config.VITE_APP_URL}/callback`
: window.location.origin + "/callback";

Then I create a custom implementation of the RedirectNavigator to use Capacitor’s Browser API for navigation:

CapacitorRedirectNavigator.ts
class CapacitorRedirectNavigator implements INavigator {
private readonly _logger = new Logger("CapacitorRedirectNavigator");
public async prepare(): Promise<IWindow> {
this._logger.create("prepare");
return {
navigate: async (params: NavigateParams): Promise<NavigateResponse> => {
this._logger.create("navigate");
// Open the OAuth URL in system browser
await Browser.open({
url: params.url,
windowName: "_self",
});
// Return immediately - we'll handle the callback separately via deep link
// This is different from RedirectNavigator which never returns (page redirects away)
return { url: params.url };
},
close: () => {
this._logger.create("close");
Browser.close().catch((error) => {
this._logger.error("Browser close failed:", error);
});
},
};
}
public async callback(): Promise<void> {
this._logger.create("callback");
return;
}
}

Also, I add a custom storage implementation to use Capacitor’s Secure Storage plugin (this is also recommended over using localStorage or capacitor/preferences for security reasons):

CapacitorSecureStorage.ts
class CapacitorSecureStorage implements AsyncStorage {
get length(): Promise<number> {
return SecureStoragePlugin.keys().then((result) => result.value.length);
}
async clear(): Promise<void> {
await SecureStoragePlugin.clear();
}
async getItem(key: string): Promise<string | null> {
try {
const result = await SecureStoragePlugin.get({ key });
return result.value;
} catch {
// Key not found or other error
return null;
}
}
async key(index: number): Promise<string | null> {
try {
const result = await SecureStoragePlugin.keys();
const keys = result.value;
if (index >= 0 && index < keys.length) {
return keys[index];
}
return null;
} catch {
return null;
}
}
async removeItem(key: string): Promise<void> {
await SecureStoragePlugin.remove({ key });
}
async setItem(key: string, value: string): Promise<void> {
await SecureStoragePlugin.set({ key, value });
}
}

Then I can initialize the UserManager with those custom implementations:

authConfig.ts
export const userManager = new UserManager(
{
authority: config.VITE_AUTHORITY ?? "http://localhost:8080/",
client_id: config.VITE_CLIENT_ID,
redirect_uri: redirectUri,
post_logout_redirect_uri: window.location.origin + "/",
scope: `openid profile offline_access email urn:zitadel:iam:org:id:${config.VITE_ORG_ID} urn:zitadel:iam:org:project:id:${config.VITE_PROJECT_ID}:aud urn:zitadel:iam:org:project:id:${config.VITE_PROJECT_ID}:roles`,
userStore: new WebStorageStateStore({
store: Capacitor.isNativePlatform()
? new CapacitorSecureStorage()
: window.localStorage,
}),
automaticSilentRenew: true,
loadUserInfo: true,
// Even more
response_type: "code",
response_mode: "query",
},
Capacitor.isNativePlatform() ? new CapacitorRedirectNavigator() : undefined
);

This will now open the system browser for authentication and handle the redirect back to your app via deep linking.

Now, to finally handle the deep link callback in your app, you can use Capacitor’s App plugin to listen for URL open events:

AppUrlListener.tsx
export const AppUrlListener = () => {
const navigate = useNavigate();
useEffect(() => {
App.addListener("appUrlOpen", async (event) => {
try {
const deepLinkUrl = new URL(event.url);
console.log("Deep link URL opened:", deepLinkUrl.href);
await Browser.close();
if (Capacitor.isNativePlatform() && event.url.includes("/callback")) {
console.log("Processing OAuth callback");
userManager.signinCallback(event.url).catch((error) => {
console.error("Failed to process OAuth callback:", error);
});
}
// split the deepLinkUrl after domain
const slug =
deepLinkUrl.pathname + deepLinkUrl.search + deepLinkUrl.hash;
console.log("Navigating to relative path:", slug);
navigate(slug);
} catch (error) {
console.error("Failed to parse deep link URL:", error);
}
});
}, []);
return null;
};

The signInCallback needs to be called explicitly since, unlike the normal redirect flow, the app is not reloaded but actually remains active in the background. Now just include the AppUrlListener component somewhere in your app (e.g., in your root component), and you’re good to go.

And this way, you can now use oidc-client-ts with Capacitor for OIDC authentication in your mobile app and web app with mostly the same codebase!