Skip to main content

Account Linking

Enable users to link multiple authentication methods (password, OAuth, magic links, etc.) to a single ZewstID account

Overview

Account linking allows users to authenticate using multiple methods while maintaining a single identity. For example, a user who initially signed up with email/password can later add Google OAuth or magic link authentication to their account.

Key Benefits

  • Users can choose their preferred sign-in method
  • No duplicate accounts for the same email
  • Seamless migration from local auth to SSO
  • Consistent user identity across all auth methods

How It Works

1. Automatic Conflict Detection

When a user tries to authenticate with a new method (e.g., magic link) using an email that already exists with a different method (e.g., password), ZewstID automatically detects the conflict:

// Response when email exists with different auth method { "error": "account_exists", "error_description": "An account with this email already exists", "existingIdentities": ["password", "google"], "linkingRequired": true, "linkingEndpoint": "/api/v1/auth/link/initiate", "suggestion": "Use the account linking flow to add..." }

2. Verification Flow

To ensure security, users must verify ownership before linking:

  1. Initiate linking: User provides email and new authentication method
  2. Verify ownership: User verifies with password OR email verification code
  3. Link accounts: ZewstID links the new method to the existing account
  4. Consistent identity: All auth methods return the same
    sub
    in JWT

3. Using the SDK

The ZewstID SDK provides a simple API for account linking:

import { ZewstID } from '@zewstid/nextjs'; const zewstid = new ZewstID({ clientId: process.env.NEXT_PUBLIC_ZEWSTID_CLIENT_ID!, apiUrl: 'https://api.zewstid.com', }); // Initiate account linking const { linkingToken, verificationRequired } = await zewstid.accountLinking.initiate({ email: 'user@example.com', newProvider: 'google' }); // Verify with password if (verificationRequired === 'password') { await zewstid.accountLinking.verify({ linkingToken, password: userPassword }); } // Or verify with email code if (verificationRequired === 'email_code') { await zewstid.accountLinking.verify({ linkingToken, code: emailCode }); }

Client Application Integration

Database Schema

Your application should use ZewstID's

sub
(subject ID) as the primary identifier:

// Prisma schema example model User { id String @id @default(cuid()) // ZewstID Integration - Stable identifier zewstIdSub String? @unique // Cached identity data (from ZewstID JWT) email String @unique name String? emailVerified Boolean @default(false) // Application-specific data organizationId String role Role @default(USER) @@index([zewstIdSub]) @@index([email]) }

Authentication Handler

Implement a lookup pattern that prioritizes

zewstIdSub
:

async function findOrCreateUser(token: string) { const { sub, email, email_verified } = decodeJWT(token); if (!email_verified) { throw new Error("Email not verified"); } // PRIMARY LOOKUP: By ZewstID sub (stable) let user = await db.user.findUnique({ where: { zewstIdSub: sub } }); if (user) return user; // MIGRATION: Check for existing email-based user const existingUser = await db.user.findUnique({ where: { email } }); if (existingUser) { // Link to ZewstID return await db.user.update({ where: { id: existingUser.id }, data: { zewstIdSub: sub, emailVerified: true } }); } // NEW USER: Create with ZewstID sub return await db.user.create({ data: { zewstIdSub: sub, email, emailVerified: true, // ... application defaults } }); }

API Reference

POST /auth/link/initiate

Initiate the account linking process.

Request Body:

{ "email": "user@example.com", "newProvider": "google", "clientId": "your-client-id" }

Response:

{ "linkingToken": "eyJhbGc...", "verificationRequired": "password", "existingIdentities": ["password"], "message": "Please verify with your password" }

POST /auth/link/verify

Verify ownership and complete linking.

Request Body:

{ "linkingToken": "eyJhbGc...", "verification": { "password": "user-password" // OR "code": "123456" } }

Response:

{ "success": true, "userId": "user-id", "linkedProvider": "google", "message": "Successfully linked..." }

GET /auth/link/identities

Get all linked authentication methods (requires authentication).

Headers:

Authorization: Bearer <access-token>

Response:

{ "identities": ["password", "google", "magic_link"], "email": "user@example.com" }

DELETE /auth/link/:provider

Unlink an authentication method (requires authentication).

Headers:

Authorization: Bearer <access-token>

Response:

{ "success": true, "unlinkedProvider": "google", "remainingIdentities": ["password", "magic_link"] }

Best Practices

DO: Use ZewstID sub as primary identifier - Always look up users by

zewstIdSub
first. Email addresses can change, but the sub is stable.

DO: Handle 409 conflict responses gracefully - When magic link or OTP returns a 409 error, guide users through the account linking flow.

DO: Notify users when accounts are linked - Send confirmation emails when new authentication methods are added for security transparency.

DON'T: Auto-link without verification - Always require password or email code verification to prevent unauthorized account access.

DON'T: Store ZewstID sub without uniqueness constraint - Ensure

zewstIdSub
has a unique constraint in your database to prevent duplicate records.

DON'T: Allow unlinking the last authentication method - Users must always have at least one way to authenticate. The API prevents this, but handle it in your UI too.

Next Steps

Was this page helpful?

Let us know how we can improve our documentation