Hi @Vestels
You are implementing user-initiated account linking in a Next.js 16+ application with a Spring Boot backend. You want to authenticate a secondary provider (Google) while preserving the primary Auth0 session, obtain the secondary identity, send it to your backend, and have the backend call the Management API to link the accounts. You have found two approaches, but neither works: connectAccount doesn't give you the secondary identity to send to your backend, and startInteractiveLogin replaces the primary session and creates a new Auth0 user.
The recommended pattern for this architecture is to use a custom secondary authentication flow that does not use connectAccount or startInteractiveLogin. Instead, initiate a popup-based authentication to the secondary provider, capture the secondary ID token on the frontend, send it to your backend, and have the backend validate it and call the Management API linking endpoint. This requires a custom implementation because Auth0's built-in flows are designed for different use cases: connectAccount is for the My Account API (frontend-driven), and startInteractiveLogin is for full session replacement.
[Root Cause]
The two built-in approaches have conflicting behaviors:
1. connectAccount (My Account API / Connected Accounts flow)
- ✅ Preserves primary session
- ✅ Does not create new Auth0 user
- ❌ Does not return secondary identity to frontend
- ❌ Does not give you a token or ID to send to backend
- ❌ Designed for frontend-driven linking, not backend-driven
2. startInteractiveLogin (Standard OAuth flow)
- ✅ Returns secondary identity/ID token
- ❌ Replaces primary session with secondary session
- ❌ Creates new Auth0 user (if email differs)
- ❌ Breaks your backend's single-user-per-email model
Your backend architecture requires:
- Primary session remains active
- Secondary identity captured and sent to backend
- Backend performs the linking via Management API
- Single Auth0 user with multiple identities
- Single database user with multiple identity records
Solution: Custom Secondary Authentication Flow
Architecture Overview:
1. User authenticated as primary (auth0|123)
2. User clicks "Connect Google"
3. Frontend initiates secondary authentication (popup or redirect)
4. Secondary authentication completes, frontend captures secondary ID token
5. Frontend sends secondary ID token to backend
6. Backend validates secondary ID token
7. Backend calls Management API to link identities
8. Primary session remains unchanged
9. Auth0 user now has both identities
10. Database user now has both identity records
Step 1: Configure Auth0 Application for Secondary Authentication
Your Auth0 application needs to support secondary authentication. Ensure your Auth0 application settings allow:
- Navigate to Auth0 Dashboard → Applications → Your Application
- Go to Settings tab
- Verify that "Allowed Callback URLs" includes your secondary auth endpoint (e.g.,
http://localhost:3000/api/auth/link/callback)
- Verify that "Allowed Logout URLs" includes your app URL
- Enable "Refresh Token Rotation" (recommended for security)
- Save changes
Step 2: Create a Secondary Auth0 Client Instance (Frontend)
Create a separate Auth0 client instance for secondary authentication that does not replace the primary session:
// lib/auth0-secondary.ts
import { Auth0Client } from '@auth0/auth0-spa-js';
let secondaryAuth0Client: Auth0Client | null = null;
export async function getSecondaryAuth0Client() {
if (secondaryAuth0Client) {
return secondaryAuth0Client;
}
secondaryAuth0Client = new Auth0Client({
domain: process.env.NEXT_PUBLIC_AUTH0_DOMAIN!,
clientId: process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID!,
authorizationParameters: {
redirect_uri: `${window.location.origin}/api/auth/link/callback`,
audience: process.env.NEXT_PUBLIC_AUTH0_AUDIENCE,
scope: 'openid profile email',
},
});
return secondaryAuth0Client;
}
export async function authenticateSecondaryProvider(connection: string) {
const client = await getSecondaryAuth0Client();
// Use popup to avoid replacing primary session
const result = await client.loginWithPopup({
authorizationParameters: {
connection,
prompt: 'login',
},
});
// Get the ID token for the secondary account
const idToken = await client.getIdTokenClaims();
return {
idToken: await client.getIdToken(),
claims: idToken,
};
}
Step 3: Create Link Endpoint (Frontend)
Create a Next.js API route that receives the secondary ID token and calls your backend:
// pages/api/auth/link/initiate.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@auth0/nextjs-auth0/edge';
export async function POST(request: NextRequest) {
try {
// Get primary session
const session = await getSession(request);
if (!session) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const { secondaryIdToken } = await request.json();
if (!secondaryIdToken) {
return NextResponse.json(
{ error: 'Secondary ID token required' },
{ status: 400 }
);
}
// Call your Spring Boot backend to perform the linking
const linkResponse = await fetch(
`${process.env.BACKEND_URL}/api/v1/users/link-account`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.accessToken}`,
},
body: JSON.stringify({
secondaryIdToken,
}),
}
);
if (!linkResponse.ok) {
const error = await linkResponse.json();
return NextResponse.json(
{ error: error.message || 'Linking failed' },
{ status: linkResponse.status }
);
}
const result = await linkResponse.json();
return NextResponse.json(result);
} catch (error) {
console.error('Link initiate error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Step 4: Create Link Button Component (Frontend)
// components/LinkAccountButton.tsx
'use client';
import { useState } from 'react';
import { authenticateSecondaryProvider } from '@/lib/auth0-secondary';
export function LinkAccountButton() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleLinkGoogle = async () => {
setLoading(true);
setError(null);
setSuccess(false);
try {
// Step 1: Authenticate with Google in a popup
const { idToken } = await authenticateSecondaryProvider('google-oauth2');
// Step 2: Send secondary ID token to your backend via Next.js API route
const response = await fetch('/api/auth/link/initiate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
secondaryIdToken: idToken,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Linking failed');
}
setSuccess(true);
// Optionally refresh user data or redirect
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
return (
<div>
<button
onClick={handleLinkGoogle}
disabled={loading}
>
{loading ? 'Linking...' : 'Connect Google Account'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
{success && <p style={{ color: 'green' }}>Account linked successfully!</p>}
</div>
);
}
Step 5: Backend Implementation (Spring Boot)
Your Spring Boot backend receives the secondary ID token, validates it, and calls the Management API:
// LinkAccountController.java
@RestController
@RequestMapping("/api/v1/users")
public class LinkAccountController {
@Autowired
private Auth0ManagementService auth0ManagementService;
@Autowired
private UserService userService;
@PostMapping("/link-account")
public ResponseEntity<?> linkAccount(
@RequestHeader("Authorization") String authHeader,
@RequestBody LinkAccountRequest request
) {
try {
// Get primary user from authenticated session
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
AuthenticatedUserIdentityDto primaryUser =
AuthenticatedUserIdentityDto.from(authentication);
// Validate secondary ID token
DecodedJWT secondaryToken = auth0ManagementService.verifyIdToken(
request.getSecondaryIdToken()
);
String secondaryUserId = secondaryToken.getSubject();
String secondaryEmail = secondaryToken.getClaim("email").asString();
// Verify emails match (optional but recommended)
if (!primaryUser.email().equals(secondaryEmail)) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("Email mismatch between accounts"));
}
// Link accounts via Management API
auth0ManagementService.linkIdentities(
primaryUser.subject(),
secondaryUserId
);
// Update your database
UserIdentityEntity secondaryIdentity = new UserIdentityEntity();
secondaryIdentity.setUserId(userService.findByEmail(primaryUser.email()).get().getId());
secondaryIdentity.setProvider(extractProvider(secondaryUserId)); // e.g., "google-oauth2"
secondaryIdentity.setProviderUserId(extractProviderUserId(secondaryUserId));
userService.saveIdentity(secondaryIdentity);
return ResponseEntity.ok(new LinkAccountResponse("Account linked successfully"));
} catch (Exception e) {
return ResponseEntity.status(500)
.body(new ErrorResponse(e.getMessage()));
}
}
private String extractProvider(String userId) {
// Extract provider from userId (e.g., "google-oauth2|12345" → "google-oauth2")
return userId.split("\\|")[0];
}
private String extractProviderUserId(String userId) {
// Extract provider user ID (e.g., "google-oauth2|12345" → "12345")
return userId.split("\\|")[1];
}
}
// Auth0ManagementService.java
@Service
public class Auth0ManagementService {
@Autowired
private Auth0ManagementClient managementClient;
public void linkIdentities(String primaryUserId, String secondaryUserId) {
// Extract provider and user_id from secondary user ID
String[] parts = secondaryUserId.split("\\|");
String provider = parts[0];
String userId = parts[1];
// Call Management API
managementClient.users()
.linkIdentity(primaryUserId, provider, userId)
.execute();
}
public DecodedJWT verifyIdToken(String idToken) {
// Verify and decode the ID token
JwtVerifier verifier = JWT.require(Algorithm.HMAC256(clientSecret))
.withIssuer(issuer)
.withAudience(clientId)
.build();
return verifier.verify(idToken);
}
}
Step 6: Update Your Primary Auth0 Client Configuration
Ensure your primary Auth0 client is configured correctly:
// lib/auth0.ts
import { Auth0Client } from '@auth0/auth0-spa-js';
export const auth0 = new Auth0Client({
domain: process.env.NEXT_PUBLIC_AUTH0_DOMAIN!,
clientId: process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID!,
authorizationParameters: {
redirect_uri: `${typeof window !== 'undefined' ? window.location.origin : ''}/api/auth/callback`,
audience: process.env.NEXT_PUBLIC_AUTH0_AUDIENCE,
scope: 'openid profile email offline_access',
},
});
Key Differences from Built-In Flows:
- Popup-based secondary auth — Uses
loginWithPopup() instead of full redirect, preserving primary session
- ID token capture — Explicitly captures and returns the secondary ID token
- Backend-driven linking — Backend validates token and calls Management API
- No session replacement — Primary session remains unchanged throughout
- Single Auth0 user — Both identities linked to the same Auth0 user
- Single database user — Both identities associated with the same database user
Security Considerations:
- Validate ID token on backend — Never trust tokens from the frontend alone
- Verify email match — Ensure both accounts have the same email before linking
- Use HTTPS — Always use HTTPS in production
- Secure token transmission — Send tokens only to your backend, never expose Management API credentials to frontend
- Rate limiting — Implement rate limiting on the linking endpoint
- Audit logging — Log all account linking operations
Troubleshooting:
If the popup is blocked:
- Ensure the popup is triggered by a direct user action (click)
- Check browser popup blocker settings
- Consider using a redirect-based flow as fallback
If the secondary ID token is invalid:
- Verify the token hasn't expired
- Check that the Auth0 domain and client ID are correct
- Ensure the token was issued for your application
If the backend linking fails:
- Verify your Management API credentials are correct
- Check that the primary user ID format is correct
- Ensure the secondary user exists in Auth0
This custom implementation gives you full control over the linking flow while preserving your primary session and backend architecture.
Kind Regards,
Nik