Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
24cf072
feat(expo): add hosted auth hook
mikepitre Jun 22, 2026
e3fcdad
chore(expo): add hosted auth changeset
mikepitre Jun 22, 2026
f04d4c8
fix(expo): activate hosted auth session
mikepitre Jun 22, 2026
26e048d
fix(expo): redeem hosted auth with code verifier
mikepitre Jun 23, 2026
ad5f7d7
fix(expo): rename hosted auth option to mode
mikepitre Jun 24, 2026
0e543d3
fix(expo): satisfy hosted auth lint rules
mikepitre Jun 24, 2026
142788e
fix(expo): publish hosted auth verifier dependencies
mikepitre Jun 24, 2026
e36ebb9
fix(expo): address hosted auth review feedback
mikepitre Jun 25, 2026
82aa2d3
fix(expo): harden hosted auth session handoff
mikepitre Jun 25, 2026
0fab777
fix(clerk-js): type hosted auth verifier body
mikepitre Jun 25, 2026
b42a61a
test(js): harden hosted auth verifier handling
mikepitre Jun 25, 2026
6b6b986
fix(clerk-js): trim hosted auth reload path
mikepitre Jun 25, 2026
af3badf
fix(expo): stabilize hosted auth CI
mikepitre Jun 25, 2026
dd7e3da
fix(expo): scope hosted auth completion to expo
mikepitre Jun 25, 2026
3b66e52
fix(expo): move hosted auth to subpath export
mikepitre Jun 25, 2026
03fd4c5
fix(expo): align hosted auth callbacks with native apps
mikepitre Jul 9, 2026
3148788
fix(expo): harden hosted auth completion
mikepitre Jul 10, 2026
13d20cc
fix(expo): tighten hosted auth flow
mikepitre Jul 10, 2026
9b6b252
fix(expo): complete hosted auth platform wiring
mikepitre Jul 10, 2026
dfa6627
fix(expo): harden hosted auth flow
mikepitre Jul 13, 2026
fb0ffab
chore(expo): release hosted auth as a minor version
mikepitre Jul 13, 2026
d7a7c1c
fix(expo): retry stale hosted auth creation
mikepitre Jul 15, 2026
1892027
fix(expo): validate hosted auth intent filters
mikepitre Jul 16, 2026
0a25df7
refactor(expo): simplify hosted auth payload helpers
mikepitre Jul 21, 2026
48379d7
Merge branch 'main' into mike/hosted-mobile-sign-in
mikepitre Jul 21, 2026
406dea5
fix(expo): type hosted auth payload access without casts
mikepitre Jul 21, 2026
5e85a86
fix(expo): align hosted auth strictness and errors with native SDKs
mikepitre Jul 22, 2026
79705c1
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 23, 2026
7b5f601
chore: update changeset
wobsoriano Jul 24, 2026
d575bbb
fix(expo): avoid Android callback collision with clerk-android intent…
mikepitre Jul 24, 2026
1e62b01
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
b4e668c
fix(expo): compare against a pre-refresh client snapshot in native sync
mikepitre Jul 27, 2026
1ab2f63
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/hosted-auth-expo.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@clerk/expo': minor
---

Introduce the `useHostedAuth()` hook for signing users in or up through Clerk's hosted Account Portal from native Expo apps.

```tsx
import { useHostedAuth } from '@clerk/expo/hosted-auth'

const { startHostedAuth } = useHostedAuth()

// Opens Account Portal on the sign-in page
await startHostedAuth()

// Or open the sign-up page first
await startHostedAuth({ mode: 'sign-up' })
```
5 changes: 5 additions & 0 deletions .changeset/native-client-sync-foreign-client.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Keep the active session when a native client change resolves to a different, signed-out client. The check that discards those clients compared a reference that the refresh had already mutated, so it never engaged and the signed-out client was applied over the current session.
41 changes: 41 additions & 0 deletions packages/expo/app.plugin.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,10 +9,12 @@
* Native modules and views are registered via Expo Modules autolinking.
*/
const {
AndroidConfig,
withXcodeProject,
withDangerousMod,
withInfoPlist,
withAppBuildGradle,
withAndroidManifest,
withEntitlementsPlist,
} = require('@expo/config-plugins');
const path = require('path');
Expand All@@ -21,6 +23,35 @@ const packageJson = require('./package.json');

const CLERK_MIN_IOS_VERSION = '17.0';

const addHostedAuthIntentFilter = (mainActivity, packageName) => {
const callbackHost = `${packageName}.hosted-callback`;
const intentFilters = mainActivity['intent-filter'] || [];
const hasAndroidName = (entries, name) => entries?.some(entry => entry.$?.['android:name'] === name);
const callbackIsRegistered = intentFilters.some(
intentFilter =>
hasAndroidName(intentFilter.action, 'android.intent.action.VIEW') &&
hasAndroidName(intentFilter.category, 'android.intent.category.DEFAULT') &&
hasAndroidName(intentFilter.category, 'android.intent.category.BROWSABLE') &&
intentFilter.data?.some(
data => data.$?.['android:scheme'] === 'clerk' && data.$?.['android:host'] === callbackHost,
),
);

if (callbackIsRegistered) {
return;
}

intentFilters.push({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': callbackHost } }],
});
mainActivity['intent-filter'] = intentFilters;
};

const withClerkIOS = config => {
console.log('✅ Clerk iOS plugin loaded');

Expand DownExpand Up@@ -94,6 +125,15 @@ const withClerkIOS = config => {
const withClerkAndroid = config => {
console.log('✅ Clerk Android plugin loaded');

config = withAndroidManifest(config, modConfig => {
const packageName = config.android?.package;
if (packageName) {
const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults);
addHostedAuthIntentFilter(mainActivity, packageName);
}
return modConfig;
});

return withAppBuildGradle(config, modConfig => {
let buildGradle = modConfig.modResults.contents;

Expand DownExpand Up@@ -320,6 +360,7 @@ const withClerkExpo = (config, props = {}) => {

module.exports = withClerkExpo;
module.exports._testing = {
addHostedAuthIntentFilter,
validateThemeJson,
isPlainObject,
VALID_COLOR_KEYS,
Expand Down
4 changes: 4 additions & 0 deletions packages/expo/hosted-auth/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
{
"main": "../dist/hosted-auth/index.js",
"types": "../dist/hosted-auth/index.d.ts"
}
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,10 @@
"types": "./dist/apple/index.d.ts",
"default": "./dist/apple/index.js"
},
"./hosted-auth": {
"types": "./dist/hosted-auth/index.d.ts",
"default": "./dist/hosted-auth/index.js"
},
"./resource-cache": {
"types": "./dist/resource-cache/index.d.ts",
"default": "./dist/resource-cache/index.js"
Expand DownExpand Up@@ -92,6 +96,7 @@
"token-cache",
"google",
"apple",
"hosted-auth",
"experimental",
"legacy",
"src/specs",
Expand Down
60 changes: 60 additions & 0 deletions packages/expo/src/__tests__/appPlugin.hostedAuth.test.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest';

// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
const { addHostedAuthIntentFilter } = require('../../app.plugin.js')._testing;

const hostedAuthIntentFilter = () => ({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': 'com.example.app.hosted-callback' } }],
});

describe('addHostedAuthIntentFilter', () => {
test('registers the canonical Android hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});

test('does not duplicate an existing hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');
addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toHaveLength(1);
});

test.each([
['VIEW action', filter => ({ ...filter, action: [] })],
[
'DEFAULT category',
filter => ({
...filter,
category: filter.category.filter(category => category.$['android:name'] !== 'android.intent.category.DEFAULT'),
}),
],
[
'BROWSABLE category',
filter => ({
...filter,
category: filter.category.filter(
category => category.$['android:name'] !== 'android.intent.category.BROWSABLE',
),
}),
],
])('registers a valid callback when a matching filter lacks the %s', (_requirement, omitRequirement) => {
const mainActivity = {
'intent-filter': [omitRequirement(hostedAuthIntentFilter())],
};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(expo): add hosted auth flow by mikepitre · Pull Request #8960 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
24cf072
feat(expo): add hosted auth hook
mikepitre Jun 22, 2026
e3fcdad
chore(expo): add hosted auth changeset
mikepitre Jun 22, 2026
f04d4c8
fix(expo): activate hosted auth session
mikepitre Jun 22, 2026
26e048d
fix(expo): redeem hosted auth with code verifier
mikepitre Jun 23, 2026
ad5f7d7
fix(expo): rename hosted auth option to mode
mikepitre Jun 24, 2026
0e543d3
fix(expo): satisfy hosted auth lint rules
mikepitre Jun 24, 2026
142788e
fix(expo): publish hosted auth verifier dependencies
mikepitre Jun 24, 2026
e36ebb9
fix(expo): address hosted auth review feedback
mikepitre Jun 25, 2026
82aa2d3
fix(expo): harden hosted auth session handoff
mikepitre Jun 25, 2026
0fab777
fix(clerk-js): type hosted auth verifier body
mikepitre Jun 25, 2026
b42a61a
test(js): harden hosted auth verifier handling
mikepitre Jun 25, 2026
6b6b986
fix(clerk-js): trim hosted auth reload path
mikepitre Jun 25, 2026
af3badf
fix(expo): stabilize hosted auth CI
mikepitre Jun 25, 2026
dd7e3da
fix(expo): scope hosted auth completion to expo
mikepitre Jun 25, 2026
3b66e52
fix(expo): move hosted auth to subpath export
mikepitre Jun 25, 2026
03fd4c5
fix(expo): align hosted auth callbacks with native apps
mikepitre Jul 9, 2026
3148788
fix(expo): harden hosted auth completion
mikepitre Jul 10, 2026
13d20cc
fix(expo): tighten hosted auth flow
mikepitre Jul 10, 2026
9b6b252
fix(expo): complete hosted auth platform wiring
mikepitre Jul 10, 2026
dfa6627
fix(expo): harden hosted auth flow
mikepitre Jul 13, 2026
fb0ffab
chore(expo): release hosted auth as a minor version
mikepitre Jul 13, 2026
d7a7c1c
fix(expo): retry stale hosted auth creation
mikepitre Jul 15, 2026
1892027
fix(expo): validate hosted auth intent filters
mikepitre Jul 16, 2026
0a25df7
refactor(expo): simplify hosted auth payload helpers
mikepitre Jul 21, 2026
48379d7
Merge branch 'main' into mike/hosted-mobile-sign-in
mikepitre Jul 21, 2026
406dea5
fix(expo): type hosted auth payload access without casts
mikepitre Jul 21, 2026
5e85a86
fix(expo): align hosted auth strictness and errors with native SDKs
mikepitre Jul 22, 2026
79705c1
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 23, 2026
7b5f601
chore: update changeset
wobsoriano Jul 24, 2026
d575bbb
fix(expo): avoid Android callback collision with clerk-android intent…
mikepitre Jul 24, 2026
1e62b01
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
b4e668c
fix(expo): compare against a pre-refresh client snapshot in native sync
mikepitre Jul 27, 2026
1ab2f63
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/hosted-auth-expo.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@clerk/expo': minor
---

Introduce the `useHostedAuth()` hook for signing users in or up through Clerk's hosted Account Portal from native Expo apps.

```tsx
import { useHostedAuth } from '@clerk/expo/hosted-auth'

const { startHostedAuth } = useHostedAuth()

// Opens Account Portal on the sign-in page
await startHostedAuth()

// Or open the sign-up page first
await startHostedAuth({ mode: 'sign-up' })
```
5 changes: 5 additions & 0 deletions .changeset/native-client-sync-foreign-client.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Keep the active session when a native client change resolves to a different, signed-out client. The check that discards those clients compared a reference that the refresh had already mutated, so it never engaged and the signed-out client was applied over the current session.
41 changes: 41 additions & 0 deletions packages/expo/app.plugin.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,10 +9,12 @@
* Native modules and views are registered via Expo Modules autolinking.
*/
const {
AndroidConfig,
withXcodeProject,
withDangerousMod,
withInfoPlist,
withAppBuildGradle,
withAndroidManifest,
withEntitlementsPlist,
} = require('@expo/config-plugins');
const path = require('path');
Expand All@@ -21,6 +23,35 @@ const packageJson = require('./package.json');

const CLERK_MIN_IOS_VERSION = '17.0';

const addHostedAuthIntentFilter = (mainActivity, packageName) => {
const callbackHost = `${packageName}.hosted-callback`;
const intentFilters = mainActivity['intent-filter'] || [];
const hasAndroidName = (entries, name) => entries?.some(entry => entry.$?.['android:name'] === name);
const callbackIsRegistered = intentFilters.some(
intentFilter =>
hasAndroidName(intentFilter.action, 'android.intent.action.VIEW') &&
hasAndroidName(intentFilter.category, 'android.intent.category.DEFAULT') &&
hasAndroidName(intentFilter.category, 'android.intent.category.BROWSABLE') &&
intentFilter.data?.some(
data => data.$?.['android:scheme'] === 'clerk' && data.$?.['android:host'] === callbackHost,
),
);

if (callbackIsRegistered) {
return;
}

intentFilters.push({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': callbackHost } }],
});
mainActivity['intent-filter'] = intentFilters;
};

const withClerkIOS = config => {
console.log('✅ Clerk iOS plugin loaded');

Expand DownExpand Up@@ -94,6 +125,15 @@ const withClerkIOS = config => {
const withClerkAndroid = config => {
console.log('✅ Clerk Android plugin loaded');

config = withAndroidManifest(config, modConfig => {
const packageName = config.android?.package;
if (packageName) {
const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults);
addHostedAuthIntentFilter(mainActivity, packageName);
}
return modConfig;
});

return withAppBuildGradle(config, modConfig => {
let buildGradle = modConfig.modResults.contents;

Expand DownExpand Up@@ -320,6 +360,7 @@ const withClerkExpo = (config, props = {}) => {

module.exports = withClerkExpo;
module.exports._testing = {
addHostedAuthIntentFilter,
validateThemeJson,
isPlainObject,
VALID_COLOR_KEYS,
Expand Down
4 changes: 4 additions & 0 deletions packages/expo/hosted-auth/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
{
"main": "../dist/hosted-auth/index.js",
"types": "../dist/hosted-auth/index.d.ts"
}
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,10 @@
"types": "./dist/apple/index.d.ts",
"default": "./dist/apple/index.js"
},
"./hosted-auth": {
"types": "./dist/hosted-auth/index.d.ts",
"default": "./dist/hosted-auth/index.js"
},
"./resource-cache": {
"types": "./dist/resource-cache/index.d.ts",
"default": "./dist/resource-cache/index.js"
Expand DownExpand Up@@ -92,6 +96,7 @@
"token-cache",
"google",
"apple",
"hosted-auth",
"experimental",
"legacy",
"src/specs",
Expand Down
60 changes: 60 additions & 0 deletions packages/expo/src/__tests__/appPlugin.hostedAuth.test.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest';

// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
const { addHostedAuthIntentFilter } = require('../../app.plugin.js')._testing;

const hostedAuthIntentFilter = () => ({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': 'com.example.app.hosted-callback' } }],
});

describe('addHostedAuthIntentFilter', () => {
test('registers the canonical Android hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});

test('does not duplicate an existing hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');
addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toHaveLength(1);
});

test.each([
['VIEW action', filter => ({ ...filter, action: [] })],
[
'DEFAULT category',
filter => ({
...filter,
category: filter.category.filter(category => category.$['android:name'] !== 'android.intent.category.DEFAULT'),
}),
],
[
'BROWSABLE category',
filter => ({
...filter,
category: filter.category.filter(
category => category.$['android:name'] !== 'android.intent.category.BROWSABLE',
),
}),
],
])('registers a valid callback when a matching filter lacks the %s', (_requirement, omitRequirement) => {
const mainActivity = {
'intent-filter': [omitRequirement(hostedAuthIntentFilter())],
};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(expo): add hosted auth flow by mikepitre · Pull Request #8960 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
24cf072
feat(expo): add hosted auth hook
mikepitre Jun 22, 2026
e3fcdad
chore(expo): add hosted auth changeset
mikepitre Jun 22, 2026
f04d4c8
fix(expo): activate hosted auth session
mikepitre Jun 22, 2026
26e048d
fix(expo): redeem hosted auth with code verifier
mikepitre Jun 23, 2026
ad5f7d7
fix(expo): rename hosted auth option to mode
mikepitre Jun 24, 2026
0e543d3
fix(expo): satisfy hosted auth lint rules
mikepitre Jun 24, 2026
142788e
fix(expo): publish hosted auth verifier dependencies
mikepitre Jun 24, 2026
e36ebb9
fix(expo): address hosted auth review feedback
mikepitre Jun 25, 2026
82aa2d3
fix(expo): harden hosted auth session handoff
mikepitre Jun 25, 2026
0fab777
fix(clerk-js): type hosted auth verifier body
mikepitre Jun 25, 2026
b42a61a
test(js): harden hosted auth verifier handling
mikepitre Jun 25, 2026
6b6b986
fix(clerk-js): trim hosted auth reload path
mikepitre Jun 25, 2026
af3badf
fix(expo): stabilize hosted auth CI
mikepitre Jun 25, 2026
dd7e3da
fix(expo): scope hosted auth completion to expo
mikepitre Jun 25, 2026
3b66e52
fix(expo): move hosted auth to subpath export
mikepitre Jun 25, 2026
03fd4c5
fix(expo): align hosted auth callbacks with native apps
mikepitre Jul 9, 2026
3148788
fix(expo): harden hosted auth completion
mikepitre Jul 10, 2026
13d20cc
fix(expo): tighten hosted auth flow
mikepitre Jul 10, 2026
9b6b252
fix(expo): complete hosted auth platform wiring
mikepitre Jul 10, 2026
dfa6627
fix(expo): harden hosted auth flow
mikepitre Jul 13, 2026
fb0ffab
chore(expo): release hosted auth as a minor version
mikepitre Jul 13, 2026
d7a7c1c
fix(expo): retry stale hosted auth creation
mikepitre Jul 15, 2026
1892027
fix(expo): validate hosted auth intent filters
mikepitre Jul 16, 2026
0a25df7
refactor(expo): simplify hosted auth payload helpers
mikepitre Jul 21, 2026
48379d7
Merge branch 'main' into mike/hosted-mobile-sign-in
mikepitre Jul 21, 2026
406dea5
fix(expo): type hosted auth payload access without casts
mikepitre Jul 21, 2026
5e85a86
fix(expo): align hosted auth strictness and errors with native SDKs
mikepitre Jul 22, 2026
79705c1
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 23, 2026
7b5f601
chore: update changeset
wobsoriano Jul 24, 2026
d575bbb
fix(expo): avoid Android callback collision with clerk-android intent…
mikepitre Jul 24, 2026
1e62b01
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
b4e668c
fix(expo): compare against a pre-refresh client snapshot in native sync
mikepitre Jul 27, 2026
1ab2f63
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/hosted-auth-expo.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@clerk/expo': minor
---

Introduce the `useHostedAuth()` hook for signing users in or up through Clerk's hosted Account Portal from native Expo apps.

```tsx
import { useHostedAuth } from '@clerk/expo/hosted-auth'

const { startHostedAuth } = useHostedAuth()

// Opens Account Portal on the sign-in page
await startHostedAuth()

// Or open the sign-up page first
await startHostedAuth({ mode: 'sign-up' })
```
5 changes: 5 additions & 0 deletions .changeset/native-client-sync-foreign-client.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Keep the active session when a native client change resolves to a different, signed-out client. The check that discards those clients compared a reference that the refresh had already mutated, so it never engaged and the signed-out client was applied over the current session.
41 changes: 41 additions & 0 deletions packages/expo/app.plugin.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,10 +9,12 @@
* Native modules and views are registered via Expo Modules autolinking.
*/
const {
AndroidConfig,
withXcodeProject,
withDangerousMod,
withInfoPlist,
withAppBuildGradle,
withAndroidManifest,
withEntitlementsPlist,
} = require('@expo/config-plugins');
const path = require('path');
Expand All@@ -21,6 +23,35 @@ const packageJson = require('./package.json');

const CLERK_MIN_IOS_VERSION = '17.0';

const addHostedAuthIntentFilter = (mainActivity, packageName) => {
const callbackHost = `${packageName}.hosted-callback`;
const intentFilters = mainActivity['intent-filter'] || [];
const hasAndroidName = (entries, name) => entries?.some(entry => entry.$?.['android:name'] === name);
const callbackIsRegistered = intentFilters.some(
intentFilter =>
hasAndroidName(intentFilter.action, 'android.intent.action.VIEW') &&
hasAndroidName(intentFilter.category, 'android.intent.category.DEFAULT') &&
hasAndroidName(intentFilter.category, 'android.intent.category.BROWSABLE') &&
intentFilter.data?.some(
data => data.$?.['android:scheme'] === 'clerk' && data.$?.['android:host'] === callbackHost,
),
);

if (callbackIsRegistered) {
return;
}

intentFilters.push({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': callbackHost } }],
});
mainActivity['intent-filter'] = intentFilters;
};

const withClerkIOS = config => {
console.log('✅ Clerk iOS plugin loaded');

Expand DownExpand Up@@ -94,6 +125,15 @@ const withClerkIOS = config => {
const withClerkAndroid = config => {
console.log('✅ Clerk Android plugin loaded');

config = withAndroidManifest(config, modConfig => {
const packageName = config.android?.package;
if (packageName) {
const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults);
addHostedAuthIntentFilter(mainActivity, packageName);
}
return modConfig;
});

return withAppBuildGradle(config, modConfig => {
let buildGradle = modConfig.modResults.contents;

Expand DownExpand Up@@ -320,6 +360,7 @@ const withClerkExpo = (config, props = {}) => {

module.exports = withClerkExpo;
module.exports._testing = {
addHostedAuthIntentFilter,
validateThemeJson,
isPlainObject,
VALID_COLOR_KEYS,
Expand Down
4 changes: 4 additions & 0 deletions packages/expo/hosted-auth/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
{
"main": "../dist/hosted-auth/index.js",
"types": "../dist/hosted-auth/index.d.ts"
}
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,10 @@
"types": "./dist/apple/index.d.ts",
"default": "./dist/apple/index.js"
},
"./hosted-auth": {
"types": "./dist/hosted-auth/index.d.ts",
"default": "./dist/hosted-auth/index.js"
},
"./resource-cache": {
"types": "./dist/resource-cache/index.d.ts",
"default": "./dist/resource-cache/index.js"
Expand DownExpand Up@@ -92,6 +96,7 @@
"token-cache",
"google",
"apple",
"hosted-auth",
"experimental",
"legacy",
"src/specs",
Expand Down
60 changes: 60 additions & 0 deletions packages/expo/src/__tests__/appPlugin.hostedAuth.test.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest';

// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
const { addHostedAuthIntentFilter } = require('../../app.plugin.js')._testing;

const hostedAuthIntentFilter = () => ({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': 'com.example.app.hosted-callback' } }],
});

describe('addHostedAuthIntentFilter', () => {
test('registers the canonical Android hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});

test('does not duplicate an existing hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');
addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toHaveLength(1);
});

test.each([
['VIEW action', filter => ({ ...filter, action: [] })],
[
'DEFAULT category',
filter => ({
...filter,
category: filter.category.filter(category => category.$['android:name'] !== 'android.intent.category.DEFAULT'),
}),
],
[
'BROWSABLE category',
filter => ({
...filter,
category: filter.category.filter(
category => category.$['android:name'] !== 'android.intent.category.BROWSABLE',
),
}),
],
])('registers a valid callback when a matching filter lacks the %s', (_requirement, omitRequirement) => {
const mainActivity = {
'intent-filter': [omitRequirement(hostedAuthIntentFilter())],
};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(expo): add hosted auth flow by mikepitre · Pull Request #8960 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
24cf072
feat(expo): add hosted auth hook
mikepitre Jun 22, 2026
e3fcdad
chore(expo): add hosted auth changeset
mikepitre Jun 22, 2026
f04d4c8
fix(expo): activate hosted auth session
mikepitre Jun 22, 2026
26e048d
fix(expo): redeem hosted auth with code verifier
mikepitre Jun 23, 2026
ad5f7d7
fix(expo): rename hosted auth option to mode
mikepitre Jun 24, 2026
0e543d3
fix(expo): satisfy hosted auth lint rules
mikepitre Jun 24, 2026
142788e
fix(expo): publish hosted auth verifier dependencies
mikepitre Jun 24, 2026
e36ebb9
fix(expo): address hosted auth review feedback
mikepitre Jun 25, 2026
82aa2d3
fix(expo): harden hosted auth session handoff
mikepitre Jun 25, 2026
0fab777
fix(clerk-js): type hosted auth verifier body
mikepitre Jun 25, 2026
b42a61a
test(js): harden hosted auth verifier handling
mikepitre Jun 25, 2026
6b6b986
fix(clerk-js): trim hosted auth reload path
mikepitre Jun 25, 2026
af3badf
fix(expo): stabilize hosted auth CI
mikepitre Jun 25, 2026
dd7e3da
fix(expo): scope hosted auth completion to expo
mikepitre Jun 25, 2026
3b66e52
fix(expo): move hosted auth to subpath export
mikepitre Jun 25, 2026
03fd4c5
fix(expo): align hosted auth callbacks with native apps
mikepitre Jul 9, 2026
3148788
fix(expo): harden hosted auth completion
mikepitre Jul 10, 2026
13d20cc
fix(expo): tighten hosted auth flow
mikepitre Jul 10, 2026
9b6b252
fix(expo): complete hosted auth platform wiring
mikepitre Jul 10, 2026
dfa6627
fix(expo): harden hosted auth flow
mikepitre Jul 13, 2026
fb0ffab
chore(expo): release hosted auth as a minor version
mikepitre Jul 13, 2026
d7a7c1c
fix(expo): retry stale hosted auth creation
mikepitre Jul 15, 2026
1892027
fix(expo): validate hosted auth intent filters
mikepitre Jul 16, 2026
0a25df7
refactor(expo): simplify hosted auth payload helpers
mikepitre Jul 21, 2026
48379d7
Merge branch 'main' into mike/hosted-mobile-sign-in
mikepitre Jul 21, 2026
406dea5
fix(expo): type hosted auth payload access without casts
mikepitre Jul 21, 2026
5e85a86
fix(expo): align hosted auth strictness and errors with native SDKs
mikepitre Jul 22, 2026
79705c1
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 23, 2026
7b5f601
chore: update changeset
wobsoriano Jul 24, 2026
d575bbb
fix(expo): avoid Android callback collision with clerk-android intent…
mikepitre Jul 24, 2026
1e62b01
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
b4e668c
fix(expo): compare against a pre-refresh client snapshot in native sync
mikepitre Jul 27, 2026
1ab2f63
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/hosted-auth-expo.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@clerk/expo': minor
---

Introduce the `useHostedAuth()` hook for signing users in or up through Clerk's hosted Account Portal from native Expo apps.

```tsx
import { useHostedAuth } from '@clerk/expo/hosted-auth'

const { startHostedAuth } = useHostedAuth()

// Opens Account Portal on the sign-in page
await startHostedAuth()

// Or open the sign-up page first
await startHostedAuth({ mode: 'sign-up' })
```
5 changes: 5 additions & 0 deletions .changeset/native-client-sync-foreign-client.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Keep the active session when a native client change resolves to a different, signed-out client. The check that discards those clients compared a reference that the refresh had already mutated, so it never engaged and the signed-out client was applied over the current session.
41 changes: 41 additions & 0 deletions packages/expo/app.plugin.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,10 +9,12 @@
* Native modules and views are registered via Expo Modules autolinking.
*/
const {
AndroidConfig,
withXcodeProject,
withDangerousMod,
withInfoPlist,
withAppBuildGradle,
withAndroidManifest,
withEntitlementsPlist,
} = require('@expo/config-plugins');
const path = require('path');
Expand All@@ -21,6 +23,35 @@ const packageJson = require('./package.json');

const CLERK_MIN_IOS_VERSION = '17.0';

const addHostedAuthIntentFilter = (mainActivity, packageName) => {
const callbackHost = `${packageName}.hosted-callback`;
const intentFilters = mainActivity['intent-filter'] || [];
const hasAndroidName = (entries, name) => entries?.some(entry => entry.$?.['android:name'] === name);
const callbackIsRegistered = intentFilters.some(
intentFilter =>
hasAndroidName(intentFilter.action, 'android.intent.action.VIEW') &&
hasAndroidName(intentFilter.category, 'android.intent.category.DEFAULT') &&
hasAndroidName(intentFilter.category, 'android.intent.category.BROWSABLE') &&
intentFilter.data?.some(
data => data.$?.['android:scheme'] === 'clerk' && data.$?.['android:host'] === callbackHost,
),
);

if (callbackIsRegistered) {
return;
}

intentFilters.push({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': callbackHost } }],
});
mainActivity['intent-filter'] = intentFilters;
};

const withClerkIOS = config => {
console.log('✅ Clerk iOS plugin loaded');

Expand DownExpand Up@@ -94,6 +125,15 @@ const withClerkIOS = config => {
const withClerkAndroid = config => {
console.log('✅ Clerk Android plugin loaded');

config = withAndroidManifest(config, modConfig => {
const packageName = config.android?.package;
if (packageName) {
const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults);
addHostedAuthIntentFilter(mainActivity, packageName);
}
return modConfig;
});

return withAppBuildGradle(config, modConfig => {
let buildGradle = modConfig.modResults.contents;

Expand DownExpand Up@@ -320,6 +360,7 @@ const withClerkExpo = (config, props = {}) => {

module.exports = withClerkExpo;
module.exports._testing = {
addHostedAuthIntentFilter,
validateThemeJson,
isPlainObject,
VALID_COLOR_KEYS,
Expand Down
4 changes: 4 additions & 0 deletions packages/expo/hosted-auth/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
{
"main": "../dist/hosted-auth/index.js",
"types": "../dist/hosted-auth/index.d.ts"
}
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,10 @@
"types": "./dist/apple/index.d.ts",
"default": "./dist/apple/index.js"
},
"./hosted-auth": {
"types": "./dist/hosted-auth/index.d.ts",
"default": "./dist/hosted-auth/index.js"
},
"./resource-cache": {
"types": "./dist/resource-cache/index.d.ts",
"default": "./dist/resource-cache/index.js"
Expand DownExpand Up@@ -92,6 +96,7 @@
"token-cache",
"google",
"apple",
"hosted-auth",
"experimental",
"legacy",
"src/specs",
Expand Down
60 changes: 60 additions & 0 deletions packages/expo/src/__tests__/appPlugin.hostedAuth.test.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest';

// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
const { addHostedAuthIntentFilter } = require('../../app.plugin.js')._testing;

const hostedAuthIntentFilter = () => ({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': 'com.example.app.hosted-callback' } }],
});

describe('addHostedAuthIntentFilter', () => {
test('registers the canonical Android hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});

test('does not duplicate an existing hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');
addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toHaveLength(1);
});

test.each([
['VIEW action', filter => ({ ...filter, action: [] })],
[
'DEFAULT category',
filter => ({
...filter,
category: filter.category.filter(category => category.$['android:name'] !== 'android.intent.category.DEFAULT'),
}),
],
[
'BROWSABLE category',
filter => ({
...filter,
category: filter.category.filter(
category => category.$['android:name'] !== 'android.intent.category.BROWSABLE',
),
}),
],
])('registers a valid callback when a matching filter lacks the %s', (_requirement, omitRequirement) => {
const mainActivity = {
'intent-filter': [omitRequirement(hostedAuthIntentFilter())],
};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(expo): add hosted auth flow by mikepitre · Pull Request #8960 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
24cf072
feat(expo): add hosted auth hook
mikepitre Jun 22, 2026
e3fcdad
chore(expo): add hosted auth changeset
mikepitre Jun 22, 2026
f04d4c8
fix(expo): activate hosted auth session
mikepitre Jun 22, 2026
26e048d
fix(expo): redeem hosted auth with code verifier
mikepitre Jun 23, 2026
ad5f7d7
fix(expo): rename hosted auth option to mode
mikepitre Jun 24, 2026
0e543d3
fix(expo): satisfy hosted auth lint rules
mikepitre Jun 24, 2026
142788e
fix(expo): publish hosted auth verifier dependencies
mikepitre Jun 24, 2026
e36ebb9
fix(expo): address hosted auth review feedback
mikepitre Jun 25, 2026
82aa2d3
fix(expo): harden hosted auth session handoff
mikepitre Jun 25, 2026
0fab777
fix(clerk-js): type hosted auth verifier body
mikepitre Jun 25, 2026
b42a61a
test(js): harden hosted auth verifier handling
mikepitre Jun 25, 2026
6b6b986
fix(clerk-js): trim hosted auth reload path
mikepitre Jun 25, 2026
af3badf
fix(expo): stabilize hosted auth CI
mikepitre Jun 25, 2026
dd7e3da
fix(expo): scope hosted auth completion to expo
mikepitre Jun 25, 2026
3b66e52
fix(expo): move hosted auth to subpath export
mikepitre Jun 25, 2026
03fd4c5
fix(expo): align hosted auth callbacks with native apps
mikepitre Jul 9, 2026
3148788
fix(expo): harden hosted auth completion
mikepitre Jul 10, 2026
13d20cc
fix(expo): tighten hosted auth flow
mikepitre Jul 10, 2026
9b6b252
fix(expo): complete hosted auth platform wiring
mikepitre Jul 10, 2026
dfa6627
fix(expo): harden hosted auth flow
mikepitre Jul 13, 2026
fb0ffab
chore(expo): release hosted auth as a minor version
mikepitre Jul 13, 2026
d7a7c1c
fix(expo): retry stale hosted auth creation
mikepitre Jul 15, 2026
1892027
fix(expo): validate hosted auth intent filters
mikepitre Jul 16, 2026
0a25df7
refactor(expo): simplify hosted auth payload helpers
mikepitre Jul 21, 2026
48379d7
Merge branch 'main' into mike/hosted-mobile-sign-in
mikepitre Jul 21, 2026
406dea5
fix(expo): type hosted auth payload access without casts
mikepitre Jul 21, 2026
5e85a86
fix(expo): align hosted auth strictness and errors with native SDKs
mikepitre Jul 22, 2026
79705c1
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 23, 2026
7b5f601
chore: update changeset
wobsoriano Jul 24, 2026
d575bbb
fix(expo): avoid Android callback collision with clerk-android intent…
mikepitre Jul 24, 2026
1e62b01
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
b4e668c
fix(expo): compare against a pre-refresh client snapshot in native sync
mikepitre Jul 27, 2026
1ab2f63
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/hosted-auth-expo.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@clerk/expo': minor
---

Introduce the `useHostedAuth()` hook for signing users in or up through Clerk's hosted Account Portal from native Expo apps.

```tsx
import { useHostedAuth } from '@clerk/expo/hosted-auth'

const { startHostedAuth } = useHostedAuth()

// Opens Account Portal on the sign-in page
await startHostedAuth()

// Or open the sign-up page first
await startHostedAuth({ mode: 'sign-up' })
```
5 changes: 5 additions & 0 deletions .changeset/native-client-sync-foreign-client.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Keep the active session when a native client change resolves to a different, signed-out client. The check that discards those clients compared a reference that the refresh had already mutated, so it never engaged and the signed-out client was applied over the current session.
41 changes: 41 additions & 0 deletions packages/expo/app.plugin.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,10 +9,12 @@
* Native modules and views are registered via Expo Modules autolinking.
*/
const {
AndroidConfig,
withXcodeProject,
withDangerousMod,
withInfoPlist,
withAppBuildGradle,
withAndroidManifest,
withEntitlementsPlist,
} = require('@expo/config-plugins');
const path = require('path');
Expand All@@ -21,6 +23,35 @@ const packageJson = require('./package.json');

const CLERK_MIN_IOS_VERSION = '17.0';

const addHostedAuthIntentFilter = (mainActivity, packageName) => {
const callbackHost = `${packageName}.hosted-callback`;
const intentFilters = mainActivity['intent-filter'] || [];
const hasAndroidName = (entries, name) => entries?.some(entry => entry.$?.['android:name'] === name);
const callbackIsRegistered = intentFilters.some(
intentFilter =>
hasAndroidName(intentFilter.action, 'android.intent.action.VIEW') &&
hasAndroidName(intentFilter.category, 'android.intent.category.DEFAULT') &&
hasAndroidName(intentFilter.category, 'android.intent.category.BROWSABLE') &&
intentFilter.data?.some(
data => data.$?.['android:scheme'] === 'clerk' && data.$?.['android:host'] === callbackHost,
),
);

if (callbackIsRegistered) {
return;
}

intentFilters.push({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': callbackHost } }],
});
mainActivity['intent-filter'] = intentFilters;
};

const withClerkIOS = config => {
console.log('✅ Clerk iOS plugin loaded');

Expand DownExpand Up@@ -94,6 +125,15 @@ const withClerkIOS = config => {
const withClerkAndroid = config => {
console.log('✅ Clerk Android plugin loaded');

config = withAndroidManifest(config, modConfig => {
const packageName = config.android?.package;
if (packageName) {
const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults);
addHostedAuthIntentFilter(mainActivity, packageName);
}
return modConfig;
});

return withAppBuildGradle(config, modConfig => {
let buildGradle = modConfig.modResults.contents;

Expand DownExpand Up@@ -320,6 +360,7 @@ const withClerkExpo = (config, props = {}) => {

module.exports = withClerkExpo;
module.exports._testing = {
addHostedAuthIntentFilter,
validateThemeJson,
isPlainObject,
VALID_COLOR_KEYS,
Expand Down
4 changes: 4 additions & 0 deletions packages/expo/hosted-auth/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
{
"main": "../dist/hosted-auth/index.js",
"types": "../dist/hosted-auth/index.d.ts"
}
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,10 @@
"types": "./dist/apple/index.d.ts",
"default": "./dist/apple/index.js"
},
"./hosted-auth": {
"types": "./dist/hosted-auth/index.d.ts",
"default": "./dist/hosted-auth/index.js"
},
"./resource-cache": {
"types": "./dist/resource-cache/index.d.ts",
"default": "./dist/resource-cache/index.js"
Expand DownExpand Up@@ -92,6 +96,7 @@
"token-cache",
"google",
"apple",
"hosted-auth",
"experimental",
"legacy",
"src/specs",
Expand Down
60 changes: 60 additions & 0 deletions packages/expo/src/__tests__/appPlugin.hostedAuth.test.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest';

// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
const { addHostedAuthIntentFilter } = require('../../app.plugin.js')._testing;

const hostedAuthIntentFilter = () => ({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': 'com.example.app.hosted-callback' } }],
});

describe('addHostedAuthIntentFilter', () => {
test('registers the canonical Android hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});

test('does not duplicate an existing hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');
addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toHaveLength(1);
});

test.each([
['VIEW action', filter => ({ ...filter, action: [] })],
[
'DEFAULT category',
filter => ({
...filter,
category: filter.category.filter(category => category.$['android:name'] !== 'android.intent.category.DEFAULT'),
}),
],
[
'BROWSABLE category',
filter => ({
...filter,
category: filter.category.filter(
category => category.$['android:name'] !== 'android.intent.category.BROWSABLE',
),
}),
],
])('registers a valid callback when a matching filter lacks the %s', (_requirement, omitRequirement) => {
const mainActivity = {
'intent-filter': [omitRequirement(hostedAuthIntentFilter())],
};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(expo): add hosted auth flow by mikepitre · Pull Request #8960 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
24cf072
feat(expo): add hosted auth hook
mikepitre Jun 22, 2026
e3fcdad
chore(expo): add hosted auth changeset
mikepitre Jun 22, 2026
f04d4c8
fix(expo): activate hosted auth session
mikepitre Jun 22, 2026
26e048d
fix(expo): redeem hosted auth with code verifier
mikepitre Jun 23, 2026
ad5f7d7
fix(expo): rename hosted auth option to mode
mikepitre Jun 24, 2026
0e543d3
fix(expo): satisfy hosted auth lint rules
mikepitre Jun 24, 2026
142788e
fix(expo): publish hosted auth verifier dependencies
mikepitre Jun 24, 2026
e36ebb9
fix(expo): address hosted auth review feedback
mikepitre Jun 25, 2026
82aa2d3
fix(expo): harden hosted auth session handoff
mikepitre Jun 25, 2026
0fab777
fix(clerk-js): type hosted auth verifier body
mikepitre Jun 25, 2026
b42a61a
test(js): harden hosted auth verifier handling
mikepitre Jun 25, 2026
6b6b986
fix(clerk-js): trim hosted auth reload path
mikepitre Jun 25, 2026
af3badf
fix(expo): stabilize hosted auth CI
mikepitre Jun 25, 2026
dd7e3da
fix(expo): scope hosted auth completion to expo
mikepitre Jun 25, 2026
3b66e52
fix(expo): move hosted auth to subpath export
mikepitre Jun 25, 2026
03fd4c5
fix(expo): align hosted auth callbacks with native apps
mikepitre Jul 9, 2026
3148788
fix(expo): harden hosted auth completion
mikepitre Jul 10, 2026
13d20cc
fix(expo): tighten hosted auth flow
mikepitre Jul 10, 2026
9b6b252
fix(expo): complete hosted auth platform wiring
mikepitre Jul 10, 2026
dfa6627
fix(expo): harden hosted auth flow
mikepitre Jul 13, 2026
fb0ffab
chore(expo): release hosted auth as a minor version
mikepitre Jul 13, 2026
d7a7c1c
fix(expo): retry stale hosted auth creation
mikepitre Jul 15, 2026
1892027
fix(expo): validate hosted auth intent filters
mikepitre Jul 16, 2026
0a25df7
refactor(expo): simplify hosted auth payload helpers
mikepitre Jul 21, 2026
48379d7
Merge branch 'main' into mike/hosted-mobile-sign-in
mikepitre Jul 21, 2026
406dea5
fix(expo): type hosted auth payload access without casts
mikepitre Jul 21, 2026
5e85a86
fix(expo): align hosted auth strictness and errors with native SDKs
mikepitre Jul 22, 2026
79705c1
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 23, 2026
7b5f601
chore: update changeset
wobsoriano Jul 24, 2026
d575bbb
fix(expo): avoid Android callback collision with clerk-android intent…
mikepitre Jul 24, 2026
1e62b01
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
b4e668c
fix(expo): compare against a pre-refresh client snapshot in native sync
mikepitre Jul 27, 2026
1ab2f63
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/hosted-auth-expo.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@clerk/expo': minor
---

Introduce the `useHostedAuth()` hook for signing users in or up through Clerk's hosted Account Portal from native Expo apps.

```tsx
import { useHostedAuth } from '@clerk/expo/hosted-auth'

const { startHostedAuth } = useHostedAuth()

// Opens Account Portal on the sign-in page
await startHostedAuth()

// Or open the sign-up page first
await startHostedAuth({ mode: 'sign-up' })
```
5 changes: 5 additions & 0 deletions .changeset/native-client-sync-foreign-client.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Keep the active session when a native client change resolves to a different, signed-out client. The check that discards those clients compared a reference that the refresh had already mutated, so it never engaged and the signed-out client was applied over the current session.
41 changes: 41 additions & 0 deletions packages/expo/app.plugin.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,10 +9,12 @@
* Native modules and views are registered via Expo Modules autolinking.
*/
const {
AndroidConfig,
withXcodeProject,
withDangerousMod,
withInfoPlist,
withAppBuildGradle,
withAndroidManifest,
withEntitlementsPlist,
} = require('@expo/config-plugins');
const path = require('path');
Expand All@@ -21,6 +23,35 @@ const packageJson = require('./package.json');

const CLERK_MIN_IOS_VERSION = '17.0';

const addHostedAuthIntentFilter = (mainActivity, packageName) => {
const callbackHost = `${packageName}.hosted-callback`;
const intentFilters = mainActivity['intent-filter'] || [];
const hasAndroidName = (entries, name) => entries?.some(entry => entry.$?.['android:name'] === name);
const callbackIsRegistered = intentFilters.some(
intentFilter =>
hasAndroidName(intentFilter.action, 'android.intent.action.VIEW') &&
hasAndroidName(intentFilter.category, 'android.intent.category.DEFAULT') &&
hasAndroidName(intentFilter.category, 'android.intent.category.BROWSABLE') &&
intentFilter.data?.some(
data => data.$?.['android:scheme'] === 'clerk' && data.$?.['android:host'] === callbackHost,
),
);

if (callbackIsRegistered) {
return;
}

intentFilters.push({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': callbackHost } }],
});
mainActivity['intent-filter'] = intentFilters;
};

const withClerkIOS = config => {
console.log('✅ Clerk iOS plugin loaded');

Expand DownExpand Up@@ -94,6 +125,15 @@ const withClerkIOS = config => {
const withClerkAndroid = config => {
console.log('✅ Clerk Android plugin loaded');

config = withAndroidManifest(config, modConfig => {
const packageName = config.android?.package;
if (packageName) {
const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults);
addHostedAuthIntentFilter(mainActivity, packageName);
}
return modConfig;
});

return withAppBuildGradle(config, modConfig => {
let buildGradle = modConfig.modResults.contents;

Expand DownExpand Up@@ -320,6 +360,7 @@ const withClerkExpo = (config, props = {}) => {

module.exports = withClerkExpo;
module.exports._testing = {
addHostedAuthIntentFilter,
validateThemeJson,
isPlainObject,
VALID_COLOR_KEYS,
Expand Down
4 changes: 4 additions & 0 deletions packages/expo/hosted-auth/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
{
"main": "../dist/hosted-auth/index.js",
"types": "../dist/hosted-auth/index.d.ts"
}
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,10 @@
"types": "./dist/apple/index.d.ts",
"default": "./dist/apple/index.js"
},
"./hosted-auth": {
"types": "./dist/hosted-auth/index.d.ts",
"default": "./dist/hosted-auth/index.js"
},
"./resource-cache": {
"types": "./dist/resource-cache/index.d.ts",
"default": "./dist/resource-cache/index.js"
Expand DownExpand Up@@ -92,6 +96,7 @@
"token-cache",
"google",
"apple",
"hosted-auth",
"experimental",
"legacy",
"src/specs",
Expand Down
60 changes: 60 additions & 0 deletions packages/expo/src/__tests__/appPlugin.hostedAuth.test.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest';

// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
const { addHostedAuthIntentFilter } = require('../../app.plugin.js')._testing;

const hostedAuthIntentFilter = () => ({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': 'com.example.app.hosted-callback' } }],
});

describe('addHostedAuthIntentFilter', () => {
test('registers the canonical Android hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});

test('does not duplicate an existing hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');
addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toHaveLength(1);
});

test.each([
['VIEW action', filter => ({ ...filter, action: [] })],
[
'DEFAULT category',
filter => ({
...filter,
category: filter.category.filter(category => category.$['android:name'] !== 'android.intent.category.DEFAULT'),
}),
],
[
'BROWSABLE category',
filter => ({
...filter,
category: filter.category.filter(
category => category.$['android:name'] !== 'android.intent.category.BROWSABLE',
),
}),
],
])('registers a valid callback when a matching filter lacks the %s', (_requirement, omitRequirement) => {
const mainActivity = {
'intent-filter': [omitRequirement(hostedAuthIntentFilter())],
};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(expo): add hosted auth flow by mikepitre · Pull Request #8960 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
24cf072
feat(expo): add hosted auth hook
mikepitre Jun 22, 2026
e3fcdad
chore(expo): add hosted auth changeset
mikepitre Jun 22, 2026
f04d4c8
fix(expo): activate hosted auth session
mikepitre Jun 22, 2026
26e048d
fix(expo): redeem hosted auth with code verifier
mikepitre Jun 23, 2026
ad5f7d7
fix(expo): rename hosted auth option to mode
mikepitre Jun 24, 2026
0e543d3
fix(expo): satisfy hosted auth lint rules
mikepitre Jun 24, 2026
142788e
fix(expo): publish hosted auth verifier dependencies
mikepitre Jun 24, 2026
e36ebb9
fix(expo): address hosted auth review feedback
mikepitre Jun 25, 2026
82aa2d3
fix(expo): harden hosted auth session handoff
mikepitre Jun 25, 2026
0fab777
fix(clerk-js): type hosted auth verifier body
mikepitre Jun 25, 2026
b42a61a
test(js): harden hosted auth verifier handling
mikepitre Jun 25, 2026
6b6b986
fix(clerk-js): trim hosted auth reload path
mikepitre Jun 25, 2026
af3badf
fix(expo): stabilize hosted auth CI
mikepitre Jun 25, 2026
dd7e3da
fix(expo): scope hosted auth completion to expo
mikepitre Jun 25, 2026
3b66e52
fix(expo): move hosted auth to subpath export
mikepitre Jun 25, 2026
03fd4c5
fix(expo): align hosted auth callbacks with native apps
mikepitre Jul 9, 2026
3148788
fix(expo): harden hosted auth completion
mikepitre Jul 10, 2026
13d20cc
fix(expo): tighten hosted auth flow
mikepitre Jul 10, 2026
9b6b252
fix(expo): complete hosted auth platform wiring
mikepitre Jul 10, 2026
dfa6627
fix(expo): harden hosted auth flow
mikepitre Jul 13, 2026
fb0ffab
chore(expo): release hosted auth as a minor version
mikepitre Jul 13, 2026
d7a7c1c
fix(expo): retry stale hosted auth creation
mikepitre Jul 15, 2026
1892027
fix(expo): validate hosted auth intent filters
mikepitre Jul 16, 2026
0a25df7
refactor(expo): simplify hosted auth payload helpers
mikepitre Jul 21, 2026
48379d7
Merge branch 'main' into mike/hosted-mobile-sign-in
mikepitre Jul 21, 2026
406dea5
fix(expo): type hosted auth payload access without casts
mikepitre Jul 21, 2026
5e85a86
fix(expo): align hosted auth strictness and errors with native SDKs
mikepitre Jul 22, 2026
79705c1
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 23, 2026
7b5f601
chore: update changeset
wobsoriano Jul 24, 2026
d575bbb
fix(expo): avoid Android callback collision with clerk-android intent…
mikepitre Jul 24, 2026
1e62b01
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
b4e668c
fix(expo): compare against a pre-refresh client snapshot in native sync
mikepitre Jul 27, 2026
1ab2f63
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/hosted-auth-expo.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@clerk/expo': minor
---

Introduce the `useHostedAuth()` hook for signing users in or up through Clerk's hosted Account Portal from native Expo apps.

```tsx
import { useHostedAuth } from '@clerk/expo/hosted-auth'

const { startHostedAuth } = useHostedAuth()

// Opens Account Portal on the sign-in page
await startHostedAuth()

// Or open the sign-up page first
await startHostedAuth({ mode: 'sign-up' })
```
5 changes: 5 additions & 0 deletions .changeset/native-client-sync-foreign-client.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Keep the active session when a native client change resolves to a different, signed-out client. The check that discards those clients compared a reference that the refresh had already mutated, so it never engaged and the signed-out client was applied over the current session.
41 changes: 41 additions & 0 deletions packages/expo/app.plugin.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,10 +9,12 @@
* Native modules and views are registered via Expo Modules autolinking.
*/
const {
AndroidConfig,
withXcodeProject,
withDangerousMod,
withInfoPlist,
withAppBuildGradle,
withAndroidManifest,
withEntitlementsPlist,
} = require('@expo/config-plugins');
const path = require('path');
Expand All@@ -21,6 +23,35 @@ const packageJson = require('./package.json');

const CLERK_MIN_IOS_VERSION = '17.0';

const addHostedAuthIntentFilter = (mainActivity, packageName) => {
const callbackHost = `${packageName}.hosted-callback`;
const intentFilters = mainActivity['intent-filter'] || [];
const hasAndroidName = (entries, name) => entries?.some(entry => entry.$?.['android:name'] === name);
const callbackIsRegistered = intentFilters.some(
intentFilter =>
hasAndroidName(intentFilter.action, 'android.intent.action.VIEW') &&
hasAndroidName(intentFilter.category, 'android.intent.category.DEFAULT') &&
hasAndroidName(intentFilter.category, 'android.intent.category.BROWSABLE') &&
intentFilter.data?.some(
data => data.$?.['android:scheme'] === 'clerk' && data.$?.['android:host'] === callbackHost,
),
);

if (callbackIsRegistered) {
return;
}

intentFilters.push({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': callbackHost } }],
});
mainActivity['intent-filter'] = intentFilters;
};

const withClerkIOS = config => {
console.log('✅ Clerk iOS plugin loaded');

Expand DownExpand Up@@ -94,6 +125,15 @@ const withClerkIOS = config => {
const withClerkAndroid = config => {
console.log('✅ Clerk Android plugin loaded');

config = withAndroidManifest(config, modConfig => {
const packageName = config.android?.package;
if (packageName) {
const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults);
addHostedAuthIntentFilter(mainActivity, packageName);
}
return modConfig;
});

return withAppBuildGradle(config, modConfig => {
let buildGradle = modConfig.modResults.contents;

Expand DownExpand Up@@ -320,6 +360,7 @@ const withClerkExpo = (config, props = {}) => {

module.exports = withClerkExpo;
module.exports._testing = {
addHostedAuthIntentFilter,
validateThemeJson,
isPlainObject,
VALID_COLOR_KEYS,
Expand Down
4 changes: 4 additions & 0 deletions packages/expo/hosted-auth/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
{
"main": "../dist/hosted-auth/index.js",
"types": "../dist/hosted-auth/index.d.ts"
}
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,10 @@
"types": "./dist/apple/index.d.ts",
"default": "./dist/apple/index.js"
},
"./hosted-auth": {
"types": "./dist/hosted-auth/index.d.ts",
"default": "./dist/hosted-auth/index.js"
},
"./resource-cache": {
"types": "./dist/resource-cache/index.d.ts",
"default": "./dist/resource-cache/index.js"
Expand DownExpand Up@@ -92,6 +96,7 @@
"token-cache",
"google",
"apple",
"hosted-auth",
"experimental",
"legacy",
"src/specs",
Expand Down
60 changes: 60 additions & 0 deletions packages/expo/src/__tests__/appPlugin.hostedAuth.test.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest';

// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
const { addHostedAuthIntentFilter } = require('../../app.plugin.js')._testing;

const hostedAuthIntentFilter = () => ({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': 'com.example.app.hosted-callback' } }],
});

describe('addHostedAuthIntentFilter', () => {
test('registers the canonical Android hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});

test('does not duplicate an existing hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');
addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toHaveLength(1);
});

test.each([
['VIEW action', filter => ({ ...filter, action: [] })],
[
'DEFAULT category',
filter => ({
...filter,
category: filter.category.filter(category => category.$['android:name'] !== 'android.intent.category.DEFAULT'),
}),
],
[
'BROWSABLE category',
filter => ({
...filter,
category: filter.category.filter(
category => category.$['android:name'] !== 'android.intent.category.BROWSABLE',
),
}),
],
])('registers a valid callback when a matching filter lacks the %s', (_requirement, omitRequirement) => {
const mainActivity = {
'intent-filter': [omitRequirement(hostedAuthIntentFilter())],
};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(expo): add hosted auth flow by mikepitre · Pull Request #8960 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
24cf072
feat(expo): add hosted auth hook
mikepitre Jun 22, 2026
e3fcdad
chore(expo): add hosted auth changeset
mikepitre Jun 22, 2026
f04d4c8
fix(expo): activate hosted auth session
mikepitre Jun 22, 2026
26e048d
fix(expo): redeem hosted auth with code verifier
mikepitre Jun 23, 2026
ad5f7d7
fix(expo): rename hosted auth option to mode
mikepitre Jun 24, 2026
0e543d3
fix(expo): satisfy hosted auth lint rules
mikepitre Jun 24, 2026
142788e
fix(expo): publish hosted auth verifier dependencies
mikepitre Jun 24, 2026
e36ebb9
fix(expo): address hosted auth review feedback
mikepitre Jun 25, 2026
82aa2d3
fix(expo): harden hosted auth session handoff
mikepitre Jun 25, 2026
0fab777
fix(clerk-js): type hosted auth verifier body
mikepitre Jun 25, 2026
b42a61a
test(js): harden hosted auth verifier handling
mikepitre Jun 25, 2026
6b6b986
fix(clerk-js): trim hosted auth reload path
mikepitre Jun 25, 2026
af3badf
fix(expo): stabilize hosted auth CI
mikepitre Jun 25, 2026
dd7e3da
fix(expo): scope hosted auth completion to expo
mikepitre Jun 25, 2026
3b66e52
fix(expo): move hosted auth to subpath export
mikepitre Jun 25, 2026
03fd4c5
fix(expo): align hosted auth callbacks with native apps
mikepitre Jul 9, 2026
3148788
fix(expo): harden hosted auth completion
mikepitre Jul 10, 2026
13d20cc
fix(expo): tighten hosted auth flow
mikepitre Jul 10, 2026
9b6b252
fix(expo): complete hosted auth platform wiring
mikepitre Jul 10, 2026
dfa6627
fix(expo): harden hosted auth flow
mikepitre Jul 13, 2026
fb0ffab
chore(expo): release hosted auth as a minor version
mikepitre Jul 13, 2026
d7a7c1c
fix(expo): retry stale hosted auth creation
mikepitre Jul 15, 2026
1892027
fix(expo): validate hosted auth intent filters
mikepitre Jul 16, 2026
0a25df7
refactor(expo): simplify hosted auth payload helpers
mikepitre Jul 21, 2026
48379d7
Merge branch 'main' into mike/hosted-mobile-sign-in
mikepitre Jul 21, 2026
406dea5
fix(expo): type hosted auth payload access without casts
mikepitre Jul 21, 2026
5e85a86
fix(expo): align hosted auth strictness and errors with native SDKs
mikepitre Jul 22, 2026
79705c1
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 23, 2026
7b5f601
chore: update changeset
wobsoriano Jul 24, 2026
d575bbb
fix(expo): avoid Android callback collision with clerk-android intent…
mikepitre Jul 24, 2026
1e62b01
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
b4e668c
fix(expo): compare against a pre-refresh client snapshot in native sync
mikepitre Jul 27, 2026
1ab2f63
Merge branch 'main' into mike/hosted-mobile-sign-in
wobsoriano Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/hosted-auth-expo.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@clerk/expo': minor
---

Introduce the `useHostedAuth()` hook for signing users in or up through Clerk's hosted Account Portal from native Expo apps.

```tsx
import { useHostedAuth } from '@clerk/expo/hosted-auth'

const { startHostedAuth } = useHostedAuth()

// Opens Account Portal on the sign-in page
await startHostedAuth()

// Or open the sign-up page first
await startHostedAuth({ mode: 'sign-up' })
```
5 changes: 5 additions & 0 deletions .changeset/native-client-sync-foreign-client.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Keep the active session when a native client change resolves to a different, signed-out client. The check that discards those clients compared a reference that the refresh had already mutated, so it never engaged and the signed-out client was applied over the current session.
41 changes: 41 additions & 0 deletions packages/expo/app.plugin.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,10 +9,12 @@
* Native modules and views are registered via Expo Modules autolinking.
*/
const {
AndroidConfig,
withXcodeProject,
withDangerousMod,
withInfoPlist,
withAppBuildGradle,
withAndroidManifest,
withEntitlementsPlist,
} = require('@expo/config-plugins');
const path = require('path');
Expand All@@ -21,6 +23,35 @@ const packageJson = require('./package.json');

const CLERK_MIN_IOS_VERSION = '17.0';

const addHostedAuthIntentFilter = (mainActivity, packageName) => {
const callbackHost = `${packageName}.hosted-callback`;
const intentFilters = mainActivity['intent-filter'] || [];
const hasAndroidName = (entries, name) => entries?.some(entry => entry.$?.['android:name'] === name);
const callbackIsRegistered = intentFilters.some(
intentFilter =>
hasAndroidName(intentFilter.action, 'android.intent.action.VIEW') &&
hasAndroidName(intentFilter.category, 'android.intent.category.DEFAULT') &&
hasAndroidName(intentFilter.category, 'android.intent.category.BROWSABLE') &&
intentFilter.data?.some(
data => data.$?.['android:scheme'] === 'clerk' && data.$?.['android:host'] === callbackHost,
),
);

if (callbackIsRegistered) {
return;
}

intentFilters.push({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': callbackHost } }],
});
mainActivity['intent-filter'] = intentFilters;
};

const withClerkIOS = config => {
console.log('✅ Clerk iOS plugin loaded');

Expand DownExpand Up@@ -94,6 +125,15 @@ const withClerkIOS = config => {
const withClerkAndroid = config => {
console.log('✅ Clerk Android plugin loaded');

config = withAndroidManifest(config, modConfig => {
const packageName = config.android?.package;
if (packageName) {
const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults);
addHostedAuthIntentFilter(mainActivity, packageName);
}
return modConfig;
});

return withAppBuildGradle(config, modConfig => {
let buildGradle = modConfig.modResults.contents;

Expand DownExpand Up@@ -320,6 +360,7 @@ const withClerkExpo = (config, props = {}) => {

module.exports = withClerkExpo;
module.exports._testing = {
addHostedAuthIntentFilter,
validateThemeJson,
isPlainObject,
VALID_COLOR_KEYS,
Expand Down
4 changes: 4 additions & 0 deletions packages/expo/hosted-auth/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
{
"main": "../dist/hosted-auth/index.js",
"types": "../dist/hosted-auth/index.d.ts"
}
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,10 @@
"types": "./dist/apple/index.d.ts",
"default": "./dist/apple/index.js"
},
"./hosted-auth": {
"types": "./dist/hosted-auth/index.d.ts",
"default": "./dist/hosted-auth/index.js"
},
"./resource-cache": {
"types": "./dist/resource-cache/index.d.ts",
"default": "./dist/resource-cache/index.js"
Expand DownExpand Up@@ -92,6 +96,7 @@
"token-cache",
"google",
"apple",
"hosted-auth",
"experimental",
"legacy",
"src/specs",
Expand Down
60 changes: 60 additions & 0 deletions packages/expo/src/__tests__/appPlugin.hostedAuth.test.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest';

// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
const { addHostedAuthIntentFilter } = require('../../app.plugin.js')._testing;

const hostedAuthIntentFilter = () => ({
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': 'clerk', 'android:host': 'com.example.app.hosted-callback' } }],
});

describe('addHostedAuthIntentFilter', () => {
test('registers the canonical Android hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});

test('does not duplicate an existing hosted auth callback', () => {
const mainActivity = {};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');
addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toHaveLength(1);
});

test.each([
['VIEW action', filter => ({ ...filter, action: [] })],
[
'DEFAULT category',
filter => ({
...filter,
category: filter.category.filter(category => category.$['android:name'] !== 'android.intent.category.DEFAULT'),
}),
],
[
'BROWSABLE category',
filter => ({
...filter,
category: filter.category.filter(
category => category.$['android:name'] !== 'android.intent.category.BROWSABLE',
),
}),
],
])('registers a valid callback when a matching filter lacks the %s', (_requirement, omitRequirement) => {
const mainActivity = {
'intent-filter': [omitRequirement(hostedAuthIntentFilter())],
};

addHostedAuthIntentFilter(mainActivity, 'com.example.app');

expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter());
});
});
Loading
Loading