Skip to Content
ConceptsIdentity model

Identity model

One person. One profile. Many ways in.

The picture in your head

┌─────────────────────────────┐ │ Synq User (sub) │ └──┬────────┬─────────────────┘ │ │ ┌───────────┘ └───────────┐ │ │ AuthMethods Profile fields │ │ ┌────┴─────┐ ┌─────┴─────┐ │ OAuth │ │ username │ │ links │ │ firstName │ │ (Google, │ │ lastName │ │ Apple, │ │ language │ │ …) │ │ picture │ └──────────┘ └───────────┘ ┌────┴─────┐ │ Wallets │ │ (Solana, │ │ …) │ └──────────┘

A Synq user is the subject (sub claim). Everything else attaches to that subject:

  • Any number of OAuth links — Google, Apple, Microsoft, Discord, Facebook, X, Telegram, Matrica.
  • Any number of wallets, each proven via a signed message.
  • A small set of profile fields managed by the user.

When a user signs in via any of those auth methods, your app sees the same sub and the same profile. The user is one person no matter which door they used.

What lives on the user record

Synq deliberately keeps the user record small. Here is the full shape of what Synq stores:

interface User { id: string // the `sub` claim, stable + opaque username?: string // lowercase, [a-z0-9-], 3-30 chars firstName?: string lastName?: string preferredLanguage?: string // ISO-639-1 (e.g. 'en', 'es') profilePicture?: FileRef // hosted by Synq externalProfilePictureUrl?: string // OAuth-provider picture lastSeen: string // ISO timestamp, updated on sign-in }

What you might expect to be on the user record but isn’t:

  • email — Synq does not persist the user’s email. Why? Because at sign-in time you receive the email in the id_token and through /userinfo. Persisting it in Synq would create a second source of truth that can drift from the provider. If your business model needs email, store it in your own database alongside the Synq sub.
  • phoneNumber — actually we do store this, but it’s optional and only present when the user has explicitly added it.
  • address, birthdate, gender — not stored. Out of scope.

Authentication providers — the source of truth

GET /users/me/authentication-providers returns the full union: every OAuth link + every linked wallet on the user.

interface AuthenticationProvider { type: 'oauth' | 'publicKey' id: string // oauth-only: provider?: | 'google' | 'apple' | 'microsoft' | 'discord' | 'facebook' | 'x' | 'telegram' | 'matrica' externalUserId?: string emailAddress?: string username?: string // publicKey-only: blockchain?: 'solana' address?: string label?: string // user-set, e.g. "Phantom" isPrimary?: boolean lastUsedAt?: string }

This is what powers the Connected accounts page in the user dashboard. It is also what your settings page should read if you let users manage their links.

Wallets are first-class

Most platforms make a user pick one wallet. Synq treats every wallet the user links as part of the same identity. When your app reads a user’s wallet footprint, you see all of them.

Linking a wallet

The flow:

  1. Your client asks the user to connect a wallet.
  2. The wallet exposes its public key.
  3. Synq generates a challenge message containing a nonce, a timestamp, the brand name, and the wallet address.
  4. The user’s wallet signs the message.
  5. Your client POSTs the signature + the signed message to POST /users/me/public-keys.
  6. Synq verifies the signature, checks the nonce is fresh and unused, and stores the wallet under the user.

The signature proves control of the address. It does not authorize any transaction. The wallet’s funds are not touched.

// On your client: const message = await synq.requestWalletChallenge({ address: pk.toBase58() }) const signed = await wallet.signMessage(new TextEncoder().encode(message)) await fetch('https://synq.id/users/me/public-keys', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tok}` }, body: JSON.stringify({ blockchain: 'solana', address: pk.toBase58(), signature: Buffer.from(signed).toString('base64'), signedMessage: Buffer.from(message).toString('base64'), label: 'Phantom', }), })

Wallet as a sign-in method

A user with at least one wallet linked can sign in via that wallet without an OAuth provider. The flow is the same POST /users/me/public-keys-style signature, used as primary auth instead of for linking.

If a user signs in with a wallet that has never been seen, Synq creates a new user with that wallet as the only AuthenticationProvider. The user can then link OAuth providers from inside the app.

Multi-wallet semantics

You read a user’s wallets from GET /users/me/public-keys:

[ { "id": "pk_a1b2", "blockchain": "solana", "address": "5tzFkiKscXHK5ZXCGbXbR1QYhT9bxSzwQfP4ZP8aP9NQ", "isPrimary": true, "label": "Phantom", "lastUsedAt": "2026-06-03T22:00:00Z" }, { "id": "pk_c3d4", "blockchain": "solana", "address": "9Wv3aE...", "isPrimary": false } ]

Common policies in product code:

  • Primary-wallet policy — only the wallet with isPrimary: true participates in features (token gates, eligibility, ownership). Easiest to reason about.
  • Any-wallet policy — any linked wallet that satisfies the condition unlocks the feature.
  • Sum-of-wallets policy — for token gates, sum balances across all linked wallets.

Synq does not pick a policy for you. The data is right there; the policy is your product call.

Accounts and deletion

DELETE /users/me puts the account into a deletion grace window:

  • The session cookie is destroyed immediately.
  • All OAuth tokens, linked wallets, and API keys are deleted at request time.
  • The user record is moved to a deletedUsers graveyard after the grace period (default 30 days).
  • During the window, signing in via the same (provider, externalUserId) pair resurrects the account — the deletion is reversed.

If you want true erasure (e.g. for GDPR), trigger DELETE /users/me followed by a confirmation flow on your side that prevents the resurrection path during the grace window.

A signed-in user calls POST /users/me/oauth-tokens/link?provider=apple (or whichever provider) to add an OAuth link. The flow mirrors regular sign-in except Synq attaches the result to the current user instead of creating a new one.

If the new link’s email collides with another existing user’s email, Synq returns a conflict error envelope and your app should surface that to the user with the option to merge accounts (a separate flow that requires confirmation on both sides).

Mental model summary

  • The user is the sub. Everything is attached to it.
  • Profile fields are sparse on purpose — Synq stores the minimum needed to render a recognizable identity.
  • Auth methods (OAuth links + wallets) are the doors. Multiple doors to the same user is the normal case, not the exception.
  • Wallets are full identity participants, not a separate type of user.
  • Deletion is graceful by default; trivial to make hard by gating the resurrection path.
Last updated on