Account Linking

Link existing user accounts to Muzl, enabling passwordless sign-in for users who already have accounts in your app.

What is Account Linking?

Account linking lets users with existing accounts (email/password) connect their Muzl identity. After linking, they can sign in with either:

  • Their original method (email/password)
  • Muzl (passkey/biometric)
i

Use case: You have an app with existing users. You want to add Muzl as a sign-in option without forcing users to create new accounts.

Quick Start (Using SDK)

1

Add Database Column

ALTER TABLE users ADD COLUMN muzl_service_id TEXT UNIQUE;
ALTER TABLE users ADD COLUMN muzl_linked_at TIMESTAMPTZ;
2

Add "Link with Muzl" Button

In your settings page (where user is already logged in):

settings.html
<button onclick="linkMuzlAccount()">Link with Muzl</button>

<script>
function linkMuzlAccount() {
  Muzl.linkAccount();
}
</script>
3

Handle Callback

In your callback page (same as sign-in callback):

callback.js
Muzl.handleCallback()
  .then(user => {
    if (user.flowType === 'link') {
      // Store Service ID for current logged-in user
      return fetch('/api/users/link-muzl', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ serviceId: user.serviceId })
      })
      .then(res => {
        if (!res.ok) throw new Error('Failed to link account');
        return res.json();
      });
    }
  })
  .then(() => {
    window.location.href = '/settings?linked=true';
  })
  .catch(error => {
    console.error('Account linking failed:', error);
    window.location.href = '/settings?error=link_failed';
  });
4

Store Service ID (Backend)

server.js
// POST /api/users/link-muzl
app.post('/api/users/link-muzl', async (req, res) => {
  try {
    const { serviceId } = req.body;
    const userId = req.session.userId; // Current logged-in user
    
    if (!userId) {
      return res.status(401).json({ error: 'Not authenticated' });
    }
    
    if (!serviceId) {
      return res.status(400).json({ error: 'Service ID required' });
    }
    
    // Check if already linked
    const existing = await db.user.findUnique({
      where: { id: userId }
    });
    
    if (existing?.muzl_service_id) {
      return res.status(400).json({ error: 'Account already linked' });
    }
    
    await db.user.update({
      where: { id: userId },
      data: {
        muzl_service_id: serviceId,
        muzl_linked_at: new Date()
      }
    });
    
    res.json({ success: true });
  } catch (error) {
    console.error('Link account error:', error);
    res.status(500).json({ error: 'Failed to link account' });
  }
});

Manual Implementation (Server-Side)

If you prefer server-side token exchange:

Step 1: Initiate Linking Flow

server.js
app.get('/settings/link-muzl', async (req, res) => {
  if (!req.user) return res.redirect('/login');
  
  // Generate PKCE parameters
  const codeVerifier = crypto.randomBytes(32).toString('base64url');
  const codeChallenge = crypto.createHash('sha256')
    .update(codeVerifier).digest('base64url');
  const state = crypto.randomBytes(32).toString('base64url');
  
  // Store in session
  req.session.muzlLink = { codeVerifier, state, userId: req.user.id };
  
  // Build authorization URL with link_account=true
  const authUrl = new URL('https://muzl.app/oauth/authorize');
  authUrl.searchParams.set('client_id', process.env.MUZL_CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', 'https://yourapp.com/oauth/muzl/callback');
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('scope', 'openid profile');
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('code_challenge', codeChallenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');
  authUrl.searchParams.set('link_account', 'true'); // ← This flag
  
  res.redirect(authUrl.toString());
});

Step 2: Handle Callback

server.js
app.get('/oauth/muzl/callback', async (req, res) => {
  const { code, state, error } = req.query;
  const stored = req.session.muzlLink;
  
  if (error || !stored || stored.state !== state) {
    return res.redirect('/settings?error=link_failed');
  }
  
  // Exchange code for tokens
  const tokenRes = await fetch('https://muzl.app/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: 'https://yourapp.com/oauth/muzl/callback',
      client_id: process.env.MUZL_CLIENT_ID,
      client_secret: process.env.MUZL_CLIENT_SECRET,
      code_verifier: stored.codeVerifier
    })
  });
  
  const tokens = await tokenRes.json();
  
  // Get Service ID
  const userInfoRes = await fetch('https://muzl.app/oauth/userinfo', {
    headers: { 'Authorization': `Bearer ${tokens.access_token}` }
  });
  
  const userInfo = await userInfoRes.json();
  const serviceId = userInfo.sub; // Service ID
  
  // Store Service ID
  await db.user.update({
    where: { id: stored.userId },
    data: {
      muzl_service_id: serviceId,
      muzl_linked_at: new Date()
    }
  });
  
  delete req.session.muzlLink;
  res.redirect('/settings?linked=true');
});

How It Works

  1. User clicks "Link with Muzl" while logged into your app
  2. Your app redirects to Muzl with link_account=true parameter
  3. User authenticates with Muzl (passkey/biometric)
  4. Muzl returns authorization code to your callback
  5. Your app exchanges code for tokens (same as regular OAuth)
  6. Your app gets Service ID from /oauth/userinfo
  7. Your app stores Service ID linked to the user's existing account

That's it! The user can now sign in with Muzl and your app will recognize them as the same user.

After Linking: Handling Muzl Sign-In

When a user signs in with Muzl (after linking), check if the Service ID matches a linked account:

server.js
// After OAuth completes
const userInfo = await fetch('https://muzl.app/oauth/userinfo', {
  headers: { 'Authorization': `Bearer ${accessToken}` }
}).then(r => r.json());

const serviceId = userInfo.sub;

// Check if linked account exists
const user = await db.user.findUnique({
  where: { muzl_service_id: serviceId }
});

if (user) {
  // Log them in as existing user
  req.session.userId = user.id;
  res.redirect('/dashboard');
} else {
  // New user - create account or show signup
  res.redirect('/signup');
}

Key Differences from Regular Sign-In

Regular Sign-InAccount Linking
User not logged inUser already logged in
Creates new accountLinks to existing account
No link_account parameterlink_account=true parameter
flowType: 'signin'flowType: 'link'

Troubleshooting

"User not found when signing in with Muzl"

  • Make sure you stored the Service ID correctly
  • Check that muzl_service_id column exists and has the right value

"Linking flow doesn't work"

  • Verify link_account=true is in the authorization URL
  • Check that user is logged in before initiating linking

"Service ID already exists"

  • This means the account is already linked. You can skip the linking flow or show a message.

Muzl® is a registered trademark of Muzl LLC.

© 2025 Muzl LLC. All rights reserved.