Build a custom flow for managing SSO connections
This guide demonstrates how to build a custom user interface that allows users to add, delete, and reverify their SSO connections.
Before you start
You must configure your application instance through the Clerk Dashboard for the SSO connections that you want to use.
- For social (OAuth) connection(s), see the appropriate guide for your platform.
- For enterprise connection(s), see the appropriate guide for your platform.
This guide uses Discord, Google, and GitHub as examples.
Build the custom flow
- The useUser() hook is used to get the current user's User object. The
isLoadedboolean is used to ensure that Clerk is loaded. - The
optionsarray is used to create a list of supported SSO connections. This example uses OAuth strategies. You can edit this array to include all of the SSO connections that you've enabled for your app in the Clerk Dashboard. You can also add custom SSO connections by using theoauth_custom_<name>strategy. - The
addSSO()function is used to add a new external account using thestrategythat is passed in.- It uses the
userobject to access the createExternalAccount() method. - The
createExternalAccount()method is used to create a new external account using thestrategythat is passed in. It's passed to the useReverification() hook to require the user to reverify their credentials before being able to add an external account to their account.
- It uses the
- The
unconnectedOptionsarray is used to filter out any existing external accounts from theoptionsarray. - The
normalizeProvider()function is used to strip theoauthprefix from each strategy so it can be matched with the provider field in the user's existing external accounts. - In the UI, the
unconnectedOptionsarray is used to create a list of buttons for the user to add new external accounts. - In the UI, the
Userobject is used to access theexternalAccountsproperty, which is mapped through to create a list of the user's existing external accounts. If there is an error, it is displayed to the user in the 'Status' column. If the account didn't verify when the user added it, a 'Reverify' button is displayed, which will redirect the user to the provider in order to verify their account.
'use client'
import { useUser, useReverification } from '@clerk/nextjs'
import {
CreateExternalAccountParams,
ExternalAccountResource,
OAuthStrategy,
} from '@clerk/shared/types'
import { useRouter } from 'next/navigation'
// Capitalize the first letter of the provider name
// E.g. 'discord' -> 'Discord'
const capitalize = (provider: string) => {
return `${provider.slice(0, 1).toUpperCase()}${provider.slice(1)}`
}
// Remove the 'oauth' prefix from the strategy string
// E.g. 'oauth_discord' -> 'discord'
// Used to match the strategy with the 'provider' field in externalAccounts
const normalizeProvider = (provider: string) => {
return provider.split('_')[1]
}
export default function AddAccount() {
const router = useRouter()
// Use Clerk's `useUser()` hook to get the current user's `User` object
const { isLoaded, user } = useUser()
const createExternalAccount = useReverification((params: CreateExternalAccountParams) =>
user?.createExternalAccount(params),
)
const accountDestroy = useReverification((account: ExternalAccountResource) => account.destroy())
// List the options the user can select when adding a new external account
// Edit this array to include all of your enabled SSO connections
const options: OAuthStrategy[] = ['oauth_discord', 'oauth_google', 'oauth_github']
// Handle adding the new external account
const addSSO = async (strategy: OAuthStrategy) => {
await createExternalAccount({
strategy,
redirectUrl: '/account/manage-external-accounts',
})
.then((res) => {
if (res?.verification?.externalVerificationRedirectURL) {
router.push(res.verification.externalVerificationRedirectURL.href)
}
})
.catch((err) => {
console.log('ERROR', err)
})
.finally(() => {
console.log('Redirected user to oauth provider')
})
}
// Show a loading message until Clerk loads
if (!isLoaded) return <p>Loading...</p>
// Find the external accounts from the options array that the user has not yet added to their account
// This prevents showing an 'add' button for existing external account types
const unconnectedOptions = options.filter(
(option) =>
!user?.externalAccounts.some((account) => account.provider === normalizeProvider(option)),
)
return (
<>
<div>
<p>Connected accounts</p>
{user?.externalAccounts.map((account) => {
return (
<ul key={account.id}>
<li>Provider: {capitalize(account.provider)}</li>
<li>Scopes: {account.approvedScopes}</li>
<li>
Status:{' '}
{/* This example uses the `longMessage` returned by the API. You can use account.verification.error.code to determine the error and then provide your own message to the user. */}
{account.verification?.status === 'verified'
? capitalize(account.verification?.status)
: account.verification?.error?.longMessage}
</li>
{account.verification?.status !== 'verified' &&
account.verification?.externalVerificationRedirectURL && (
<li>
<a href={account.verification?.externalVerificationRedirectURL?.href}>
Reverify {capitalize(account.provider)}
</a>
</li>
)}
<li>
<button onClick={() => accountDestroy(account)}>
Remove {capitalize(account.provider)}
</button>
</li>
</ul>
)
})}
</div>
{unconnectedOptions.length > 0 && (
<div>
<p>Add a new external account</p>
<ul>
{unconnectedOptions.map((strategy) => {
return (
<li key={strategy}>
<button onClick={() => addSSO(strategy)}>
Add {capitalize(normalizeProvider(strategy))}
</button>
</li>
)
})}
</ul>
</div>
)}
</>
)
}import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { useUser } from '@clerk/expo'
import { ExternalAccountResource, OAuthStrategy } from '@clerk/shared/types'
import { Redirect } from 'expo-router'
import { FlatList, Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'
// Capitalize the first letter of the provider name
// E.g. 'discord' -> 'Discord'
const capitalize = (provider: string) => {
return `${provider.slice(0, 1).toUpperCase()}${provider.slice(1)}`
}
// Remove the 'oauth' prefix from the strategy string
// E.g. 'oauth_discord' -> 'discord'
// Used to match the strategy with the 'provider' field in externalAccounts
const normalizeProvider = (provider: string) => {
return provider.split('_')[1]
}
export default function AddAccount() {
const { isLoaded, isSignedIn, user } = useUser()
// List the options the user can select when adding a new external account
// Edit this array to include all of your enabled SSO connections
const options: OAuthStrategy[] = ['oauth_discord', 'oauth_google', 'oauth_github']
// Handle adding the new external account
const addSSO = async (strategy: OAuthStrategy) => {
await user
?.createExternalAccount({
strategy,
redirectUrl: '/account/manage-external-accounts',
})
.then((res) => {
if (res?.verification?.externalVerificationRedirectURL) {
Linking.openURL(res.verification.externalVerificationRedirectURL.href)
}
})
.catch((err) => {
console.log('ERROR', err)
})
.finally(() => {
console.log('Redirected user to oauth provider')
})
}
// Handle removing an external account
const removeAccount = async (account: ExternalAccountResource) => {
try {
await account.destroy()
await user?.reload()
} catch (err) {
console.error('Error removing account:', err)
}
}
// Handle loading state
if (!isLoaded) {
return (
<ThemedView style={styles.container}>
<ThemedText>Loading...</ThemedText>
</ThemedView>
)
}
// Handle signed-out state
if (!isSignedIn) return <Redirect href="/sign-in" />
// Find the external accounts from the options array that the user has not yet added to their account
// This prevents showing an 'add' button for existing external account types
const unconnectedOptions = options.filter(
(option) =>
!user?.externalAccounts.some((account) => account.provider === normalizeProvider(option)),
)
return (
<ScrollView>
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Manage External Accounts
</ThemedText>
<View style={styles.section}>
<ThemedText type="subtitle" style={styles.sectionTitle}>
Connected accounts
</ThemedText>
{user?.externalAccounts.length === 0 ? (
<ThemedText style={styles.infoText}>No external accounts connected</ThemedText>
) : (
<FlatList
data={user?.externalAccounts}
scrollEnabled={false}
keyExtractor={(item) => item.id}
renderItem={({ item: account }) => (
<View style={styles.accountCard}>
<View style={styles.accountInfo}>
<ThemedText style={styles.accountProvider}>
{capitalize(account.provider)}
</ThemedText>
<ThemedText style={styles.accountDetail}>
Scopes: {account.approvedScopes}
</ThemedText>
<View style={styles.statusRow}>
<ThemedText style={styles.accountDetail}>Status: </ThemedText>
{account.verification?.status === 'verified' ? (
<ThemedText style={styles.verifiedText}>
{capitalize(account.verification?.status)}
</ThemedText>
) : (
<ThemedText style={styles.errorText}>
{account.verification?.error?.longMessage}
</ThemedText>
)}
</View>
</View>
<View style={styles.accountActions}>
{account.verification?.status !== 'verified' &&
account.verification?.externalVerificationRedirectURL && (
<Pressable
style={({ pressed }) => [
styles.smallButton,
pressed && styles.buttonPressed,
]}
onPress={() =>
Linking.openURL(
account.verification?.externalVerificationRedirectURL?.href || '',
)
}
>
<ThemedText style={styles.smallButtonText}>
Reverify {capitalize(account.provider)}
</ThemedText>
</Pressable>
)}
<Pressable
style={({ pressed }) => [
styles.smallButton,
styles.dangerButton,
pressed && styles.buttonPressed,
]}
onPress={() => removeAccount(account)}
>
<ThemedText style={styles.dangerButtonText}>
Remove {capitalize(account.provider)}
</ThemedText>
</Pressable>
</View>
</View>
)}
/>
)}
</View>
{unconnectedOptions.length > 0 && (
<View style={styles.section}>
<ThemedText type="subtitle" style={styles.sectionTitle}>
Add a new external account
</ThemedText>
<View style={styles.optionsList}>
{unconnectedOptions.map((strategy) => (
<Pressable
key={strategy}
style={({ pressed }) => [styles.optionButton, pressed && styles.buttonPressed]}
onPress={() => addSSO(strategy)}
>
<ThemedText style={styles.optionButtonText}>
Add {capitalize(normalizeProvider(strategy))}
</ThemedText>
</Pressable>
))}
</View>
</View>
)}
</ThemedView>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
title: {
marginBottom: 16,
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontWeight: '600',
fontSize: 16,
marginBottom: 12,
},
infoText: {
fontSize: 14,
opacity: 0.8,
},
accountCard: {
padding: 16,
backgroundColor: '#f5f5f5',
borderRadius: 8,
marginBottom: 12,
gap: 12,
},
accountInfo: {
gap: 6,
},
accountProvider: {
fontSize: 18,
fontWeight: '600',
},
accountDetail: {
fontSize: 14,
opacity: 0.8,
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
},
verifiedText: {
fontSize: 14,
color: '#2e7d32',
fontWeight: '500',
},
errorText: {
fontSize: 14,
color: '#c62828',
fontWeight: '500',
flex: 1,
},
accountActions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
smallButton: {
backgroundColor: '#0a7ea4',
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 6,
alignItems: 'center',
},
dangerButton: {
backgroundColor: '#c62828',
},
buttonPressed: {
opacity: 0.7,
},
smallButtonText: {
color: '#fff',
fontWeight: '600',
fontSize: 13,
},
dangerButtonText: {
color: '#fff',
fontWeight: '600',
fontSize: 13,
},
optionsList: {
gap: 8,
},
optionButton: {
backgroundColor: '#0a7ea4',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
},
optionButtonText: {
color: '#fff',
fontWeight: '600',
},
})import SwiftUI
import ClerkKit
struct ManageSSOConnectionsView: View {
@Environment(Clerk.self) private var clerk
// Edit this array to include all of your enabled SSO connections.
let options: [OAuthProvider] = [.discord, .google, .github]
var unconnectedOptions: [OAuthProvider] {
let connectedProviders = Set((clerk.user?.externalAccounts ?? []).map(provider(for:)))
return options.filter { !connectedProviders.contains($0) }
}
var body: some View {
VStack {
Text("Connected accounts")
ForEach(clerk.user?.externalAccounts ?? []) { account in
let provider = provider(for: account)
VStack(alignment: .leading) {
Text("Provider: \(provider.name)")
Text("Scopes: \(account.approvedScopes)")
Text("Status: \(account.verification?.status == .verified ? "Verified" : (account.verification?.error?.longMessage ?? "Unverified"))")
if account.verification?.status != .verified,
account.verification?.externalVerificationRedirectUrl != nil {
Button("Reverify \(provider.name)") {
Task { await reverifyExternalAccount(account: account) }
}
}
Button("Remove \(provider.name)") {
Task { await removeExternalAccount(account: account) }
}
}
}
if !unconnectedOptions.isEmpty {
Text("Add a new external account")
ForEach(unconnectedOptions) { provider in
Button("Add \(provider.name)") {
Task { await addExternalAccount(provider: provider) }
}
}
}
}
}
}
extension ManageSSOConnectionsView {
func addExternalAccount(provider: OAuthProvider) async {
guard let user = clerk.user else { return }
do {
let account = try await user.createExternalAccount(
provider: provider,
redirectUrl: "/account/manage-external-accounts"
)
// Complete provider verification if needed.
if account.verification?.externalVerificationRedirectUrl != nil {
try await account.reauthorize()
}
try await user.reload()
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
func removeExternalAccount(account: ExternalAccount) async {
do {
try await account.destroy()
try await clerk.user?.reload()
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
func reverifyExternalAccount(account: ExternalAccount) async {
do {
try await account.reauthorize()
try await clerk.user?.reload()
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
func provider(for account: ExternalAccount) -> OAuthProvider {
let strategy = account.provider.hasPrefix("oauth_") ? account.provider : "oauth_\(account.provider)"
return OAuthProvider(strategy: strategy)
}
}import com.clerk.api.Clerk
import com.clerk.api.externalaccount.ExternalAccount
import com.clerk.api.externalaccount.delete
import com.clerk.api.network.serialization.onFailure
import com.clerk.api.network.serialization.onSuccess
import com.clerk.api.sso.OAuthProvider
import com.clerk.api.user.User
import com.clerk.api.user.createExternalAccount
suspend fun addExternalAccount(user: User, provider: OAuthProvider) {
user
.createExternalAccount(
User.CreateExternalAccountParams(
provider = provider,
redirectUrl = "/account/manage-external-accounts",
)
)
.onSuccess { account ->
account.verification?.externalVerificationRedirectUrl?.let { redirectUrl ->
// Open `redirectUrl` in your browser / web auth flow.
println(redirectUrl)
}
}
.onFailure {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
}
}
suspend fun removeExternalAccount(account: ExternalAccount) {
account
.delete()
.onFailure {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
}
}Feedback
Last updated on