How to implement secondary account authentication for server-side account linking with Next.js SDK?

Hi,

I’m using Next.js 16 + @auth0/nextjs-auth0 4.29.x, Auth0 and a Spring Boot backend with the Auth0 Management API.

I’m trying to implement user-initiated account linking liek this:

Primary Auth0 session
    ↓
"Connect Google" button
    ↓
Google authentication
    ↓
Primary session remains unchanged
    ↓
Frontend sends secondary identity to backend while remaining authenticated as the primary user
    ↓
Spring Boot backend (with Management API)
    ↓
POST /api/v2/users/{primary_user_id}/identities

My backend already has its own User / UserIdentity model and deliberately performs the actual linking server-side. It already obtains a Management API token and can call the Management API. I don’t want to give the frontend Management API privileges.

The problem is the secondary authentication step. I’m currently using:

// auth0.lib.ts
export const auth0 = new Auth0Client({
  enableConnectAccountEndpoint: true,

  authorizationParameters: {
    audience: process.env.AUTH0_AUDIENCE,
    scope: "openid profile email offline_access update:current_user_identities",
  },
  async onCallback(error, context, session) {...}
});

// [locale]/api/auth/link GET()
return auth0.connectAccount({
  connection: "google-oauth2",
  returnTo: `/${locale}/profile`,
});

This appears to use the My Account API / Connected Accounts flow. The good part is that it does not create a new Auth0 user, and the Google account appears under Connected Accounts in Auth0. However, it does not add the identity to the primary user’s identities array, and the flow does not give me anything I can use to call my backend and perform the Management API linking.

I’ve found several different approaches and GitHub examples, but many seem quite old. I also don’t want to use the My Account API on the frontend if possible. I specifically want the Management API approach, with the backend handling the actual linking.

Another approach was:

// auth0.lib.ts
export const auth0 = new Auth0Client({
  authorizationParameters: {
    audience: process.env.AUTH0_AUDIENCE,
    scope: "openid profile email",
  },
  async onCallback(error, context, session) {...}
});

// [locale]/api/auth/link GET()
auth0.startInteractiveLogin({
  authorizationParameters: {
    connection: "google-oauth2",
    prompt: "login",
  },
  returnTo: `/${locale}/profile`,
});

With this approach, I can access the secondary user’s subject but not the original primary subject (without introducing custom, potentially insecure mechanism), which is useful for the linking flow. However, it also replaces the existing primary session with the secondary Google session and creates a new Auth0 user, which is not what I want.

As a result, the user can no longer properly access my application with this account, because my backend intentionally does not create a second application user when an account with the same email already exists.

Neither behavior works with my backend architecture.

My entire user handling is based on the authenticated Authentication object. For example:

// Holds the logged in user subject/ provider (e.g.: auth0|123) and email
AuthenticatedUserIdentityDto authenticatedUser = 
    AuthenticatedUserIdentityDto.from(authentication);

UserIdentityEntity identity =
    authenticateService.getAuthenticatedIdentity(authentication)
        .orElse(null);

if (identity != null) {
    return UserResponseDto.from(
        userService.findByPublicId(identity.getUserId())
    );
}

if (userService.findByEmail(authenticatedUser.email()).isPresent()) {
    throw new AccountLinkRequiredException(
        "An account with this email already exists. Account linking is required."
    );
}

if (!auth0ManagementService.userExists(authenticatedUser.subject())) {
    throw new Auth0UserNotFoundException("This Account has been deleted.");
}

What I need is therefore:

Primary session remains auth0|123 → authenticate Google as a secondary account → obtain the secondary identity/ID token → send it to my Spring Boot backend → backend calls POST /api/v2/users/{primary_user_id}/identities → Auth0 has a single user with both identities in its identities array, while my database has a single User entity with twwo associated UserIdentity records.

Ideally, how can this be made as automatic as possible in code, without unnecessary(?) Server Actions or manual intermediate steps?

Sorry for the detailed explanation — I’ve only recently started working with Auth0, and I’m trying to make sure I understand it correctly.

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:

  1. Navigate to Auth0 Dashboard → Applications → Your Application
  2. Go to Settings tab
  3. Verify that "Allowed Callback URLs" includes your secondary auth endpoint (e.g., http://localhost:3000/api/auth/link/callback)
  4. Verify that "Allowed Logout URLs" includes your app URL
  5. Enable "Refresh Token Rotation" (recommended for security)
  6. 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:

  1. Popup-based secondary auth — Uses loginWithPopup() instead of full redirect, preserving primary session
  2. ID token capture — Explicitly captures and returns the secondary ID token
  3. Backend-driven linking — Backend validates token and calls Management API
  4. No session replacement — Primary session remains unchanged throughout
  5. Single Auth0 user — Both identities linked to the same Auth0 user
  6. Single database user — Both identities associated with the same database user

Security Considerations:

  1. Validate ID token on backend — Never trust tokens from the frontend alone
  2. Verify email match — Ensure both accounts have the same email before linking
  3. Use HTTPS — Always use HTTPS in production
  4. Secure token transmission — Send tokens only to your backend, never expose Management API credentials to frontend
  5. Rate limiting — Implement rate limiting on the linking endpoint
  6. 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