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 theid_tokenand 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 Synqsub.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:
- Your client asks the user to connect a wallet.
- The wallet exposes its public key.
- Synq generates a challenge message containing a nonce, a timestamp, the brand name, and the wallet address.
- The user’s wallet signs the message.
- Your client
POSTs the signature + the signed message toPOST /users/me/public-keys. - 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: trueparticipates 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
deletedUsersgraveyard 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.
How users link more later
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.