Quickstart
Five focused minutes from zero to a signed-in user.
Using an SDK? Skip to the matching section below — each one is three files, copy-pasteable, end-to-end. The raw OIDC flow at the bottom of this page is the under-the-hood walkthrough; you only need it if you are doing something the SDKs do not yet cover.
- React (Vite, CRA, Remix)
- Next.js (App Router)
- React Native (Expo or bare)
- Solana Mobile dApp (MWA)
- Under the hood — raw OIDC
For AI agents: the
/llms.txtfile is a single machine-readable index of every page, every SDK install command, and every drop-in snippet on this site. Fetch that first.
Pick a path
The fastest way to get signed-in users is to start from the SDK that matches your stack. Each path below is a complete, copy-pasteable app — no glue you need to write, no hidden setup steps.
React (Vite / CRA / Remix)
pnpm add @synqid/react @synqid/js// src/main.tsx
import { SynqProvider } from '@synqid/react'
import { SignIn } from '@synqid/react/ui'
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(
<SynqProvider
issuer="https://api.synq.id"
clientId={import.meta.env.VITE_SYNQ_CLIENT_ID}
redirectUri={`${window.location.origin}/auth/callback`}
theme="dark"
colorScheme="system"
>
<App />
<SignIn /> {/* mount once; opens via requestSignIn() */}
</SynqProvider>,
)// src/App.tsx
import { useSynq, useUser } from '@synqid/react'
export default function App() {
const user = useUser()
const { requestSignIn, signOut } = useSynq()
if (!user) {
return <button onClick={() => requestSignIn()}>Sign in</button>
}
return (
<>
<p>Hi {user.firstName ?? user.preferredUsername}</p>
<button onClick={signOut}>Sign out</button>
</>
)
}That is it. The <SignIn> modal opens, the user picks a provider,
and useUser() re-renders with their identity. See
@synqid/react for theming, compound parts, and the
fully headless render-prop form.
Next.js App Router
pnpm add @synqid/nextjs @synqid/js// app/api/auth/[...synq]/route.ts
import { synqHandlers } from '@synqid/nextjs/server'
export const { GET, POST } = synqHandlers()// app/layout.tsx
import { SynqProvider } from '@synqid/nextjs'
import { SignIn } from '@synqid/nextjs/ui'
export default function RootLayout({ children }) {
return (
<html>
<body>
<SynqProvider theme="dark" colorScheme="system">
{children}
<SignIn />
</SynqProvider>
</body>
</html>
)
}// app/page.tsx (server component)
import { auth } from '@synqid/nextjs/server'
export default async function Home() {
const session = await auth()
if (!session) return <a href="/api/auth/signin">Sign in</a>
return <p>Hi {session.user.firstName}</p>
}Env: SYNQ_ISSUER=https://api.synq.id,
SYNQ_CLIENT_ID=…, SYNQ_CLIENT_SECRET=…,
SYNQ_REDIRECT_URI=https://<host>/api/auth/callback. The SDK reads
them via Env.getString — never expose them with NEXT_PUBLIC_*.
React Native (Expo)
pnpm add @synqid/react-native @synqid/js
pnpm add expo-secure-store expo-web-browser expo-linking @gorhom/bottom-sheet// App.tsx
import { SynqProvider, SignInSheet } from '@synqid/react-native'
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'
import Root from './Root'
export default function App() {
return (
<BottomSheetModalProvider>
<SynqProvider
issuer="https://api.synq.id"
clientId={process.env.EXPO_PUBLIC_SYNQ_CLIENT_ID!}
redirectUri="myapp://oauth/callback"
uiTheme="dark"
colorScheme="system"
>
<Root />
<SignInSheet />
</SynqProvider>
</BottomSheetModalProvider>
)
}// Root.tsx
import { Button, Text } from 'react-native'
import { useSynq, useUser } from '@synqid/react-native'
export default function Root() {
const user = useUser()
const { requestSignIn, signOut } = useSynq()
if (!user) return <Button title="Sign in" onPress={() => requestSignIn()} />
return (
<>
<Text>Hi {user.firstName}</Text>
<Button title="Sign out" onPress={signOut} />
</>
)
}Register the myapp:// scheme in app.json and add the matching
redirect URI to the App registration in the Synq Dashboard. Full
checklist (Apple, Android, deep links): @synqid/react-native.
Solana Mobile dApp (MWA)
pnpm add @synqid/solana-mobile @synqid/react-native @synqid/jsimport { useSolanaMwaAdapter } from '@synqid/solana-mobile'
import { SynqProvider, SignInSheet } from '@synqid/react-native'
export default function App() {
const walletAdapter = useSolanaMwaAdapter()
return (
<SynqProvider
issuer="https://api.synq.id"
clientId={process.env.EXPO_PUBLIC_SYNQ_CLIENT_ID!}
redirectUri="myapp://oauth/callback"
walletAdapter={walletAdapter} // SIWS wired automatically
uiTheme="dark"
>
<Root />
<SignInSheet />
</SynqProvider>
)
}walletAdapter flips the <SignInSheet> into a wallet-first flow:
the user picks their wallet via the Mobile Wallet Adapter, signs a
SIWS payload, and Synq exchanges the signature for tokens. See
@synqid/solana-mobile.
Under the hood — raw OIDC
This is the framework-agnostic OIDC plumbing that every SDK above wraps. Read it once and you will understand what each SDK is doing under the hood — useful when debugging or when you need a custom flow.
What you will build
By the end of this page you will have:
- An Organization, a Brand, and an App registered in the Synq Dashboard.
- Sign-in working in your product against
https://synq.id. - A backend that verifies access tokens using the JWKS endpoint.
- A refresh-token round-trip on access-token expiry.
The whole thing is roughly 50 lines of code on the client and 20 on the server. It looks like more here because we explain every line.
1. Register an org, a brand, and an app
Open the Synq Dashboard and follow the prompts. You will end up with three things:
- An Organization (your developer entity).
- A Brand under it (the trust surface end-users see — your logo, your colors, your name on the consent screen).
- An App under the brand (the OIDC client your code identifies as).
For the App, pick:
- Type:
web(server-rendered apps, confidential clients with aclient_secret),spa(browser-only, public, PKCE),native(mobile, public, PKCE),m2m(machine-to-machine), oragent(CLI / AI agent using the device flow). - Allowed grants:
authorization_codeandrefresh_tokencover 90% of products. - Redirect URIs: the URL Synq sends the user back to after
authorization, e.g.
https://yourapp.com/api/oauth/callback. Add one per environment (preview, prod). Must be exact matches. - Allowed scopes: at minimum
openid(required for OIDC),profile,email,offline_access(for refresh tokens).
You will get a client_id. Confidential clients also get a
client_secret — copy it now; you can rotate it later but you can’t
re-read it.
Agent-friendly: If you are an LLM agent setting this up via the SDK, the Dashboard API mirrors these dropdowns one-to-one. See Brands API for the
POSTshapes.
2. Send the user to Synq
// On your server, when the user clicks "Sign in":
import { randomBytes, createHash } from 'node:crypto'
function base64url(buf: Buffer | Uint8Array) {
return Buffer.from(buf).toString('base64url')
}
// PKCE — required for public clients, recommended for all
const codeVerifier = base64url(randomBytes(32))
const codeChallenge = base64url(createHash('sha256').update(codeVerifier).digest())
// Anti-CSRF state + nonce echoed into ID token
const state = base64url(randomBytes(16))
const nonce = base64url(randomBytes(16))
// Stash codeVerifier + state + nonce in your session.
// You will need them on the callback.
req.session.synq = { codeVerifier, state, nonce, returnTo: req.query.returnTo ?? '/' }
const params = new URLSearchParams({
client_id: process.env.SYNQ_CLIENT_ID!,
redirect_uri: process.env.SYNQ_REDIRECT_URI!,
response_type: 'code',
scope: 'openid profile email offline_access',
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
})
res.redirect(`https://synq.id/oauth2/authorize?${params}`)The user is now on Synq’s domain, signing in via Google, Apple, Discord, X, Telegram, Microsoft, Facebook, Matrica, or a Solana wallet. Synq handles every provider’s quirks; you don’t see them.
3. Handle the callback
After the user signs in (and approves consent the first time), Synq
redirects back to your redirect_uri with a code and your state.
// app.get('/api/oauth/callback', async (req, res) => { ... })
const { code, state } = req.query as { code: string; state: string }
const stash = req.session.synq
if (!stash || state !== stash.state) {
// State mismatch = abandoned or CSRF. Don't continue.
return res.status(400).send('Bad state')
}
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.SYNQ_REDIRECT_URI!,
client_id: process.env.SYNQ_CLIENT_ID!,
client_secret: process.env.SYNQ_CLIENT_SECRET!, // omit for public clients
code_verifier: stash.codeVerifier,
})
const tokenRes = await fetch('https://synq.id/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!tokenRes.ok) {
const err = await tokenRes.json()
return res.status(400).json(err) // RFC 6749 §5.2 envelope
}
const tokens = await tokenRes.json() as {
access_token: string
token_type: 'Bearer'
expires_in: number
refresh_token?: string
id_token?: string
scope?: string
}
// Persist tokens in your server session.
req.session.tokens = tokens
delete req.session.synq
res.redirect(stash.returnTo)Token shapes:
access_token— RS256 JWT. Audience is your brand slug. Use this on every authenticated backend call. See Sessions and tokens for the full claim catalog.id_token— RS256 JWT. Audience is yourclient_id. Carries the user’s identity claims (sub,email,given_name, etc.). Verifynoncematches the one you sent in step 2.refresh_token— opaque, single-use. Lasts 30 days (sliding). Use it to mint new access tokens when this one expires.
4. Verify access tokens on your backend
Every authenticated request from your client should include
Authorization: Bearer <access_token>. Verify it once per request:
import { jwtVerify, createRemoteJWKSet } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://synq.id/.well-known/jwks.json'))
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://synq.id',
audience: process.env.SYNQ_BRAND_SLUG, // access tokens carry aud = brand.slug
})
return payload // { sub, aud, scope, iat, exp, azp, ... }
}
// Express-ish middleware
app.use(async (req, res, next) => {
const auth = req.headers.authorization?.replace(/^Bearer /, '')
if (!auth) return res.status(401).json({ error: 'unauthorized' })
try {
req.user = await verifyAccessToken(auth)
next()
} catch (err) {
res.status(401).json({ error: 'invalid_token', message: String(err) })
}
})jose’s createRemoteJWKSet caches the JWKS and refetches
automatically when a JWT references a kid it has not seen.
You don’t need to manage the cache yourself.
Once @synqid/js ships this becomes:
const payload = await synq.verifyAccessToken(token)5. Refresh access tokens
When access_token.exp is past, exchange the refresh_token:
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: req.session.tokens.refresh_token!,
client_id: process.env.SYNQ_CLIENT_ID!,
client_secret: process.env.SYNQ_CLIENT_SECRET!, // confidential clients
})
const r = await fetch('https://synq.id/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const next = await r.json()
req.session.tokens = next
// next.access_token + next.refresh_token replace the previous pair.
// The previous refresh_token is now invalid.Refresh tokens are single-use. Synq rotates them on every exchange. If two requests race the same refresh, one will succeed and the other will fail with
invalid_grant. Serialize refresh in your session layer.
6. Use the user info
Either decode the id_token directly (it carries the profile claims),
or call /userinfo with the access token:
const r = await fetch('https://synq.id/userinfo', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
})
const user = await r.json()
// { sub, preferred_username?, given_name?, family_name?, picture? }Synq does not persist email on the user record. If you need email
for your business, pull it from the id_token at sign-in and store
it in your own database.
What you have now
A working sign-in with refresh, server-side token verification on every request, and access to the user’s profile. Total surface in your code: roughly 70 lines of well-typed glue.
Where to go next
- Sessions and tokens — lifetimes, revocation, introspection, the full claim catalog.
- Scopes and consent — declare brand-specific scopes, manage what apps can ask for.
- Providers — what’s different about Apple, Telegram, Solana.
- Webhooks — get notified when users sign in, grant consent, change roles.
- SDKs — drop the SDK in and remove the boilerplate above.
Common pitfalls in the first hour
A short list of every footgun I have seen developers hit, in order of frequency:
- Redirect URI mismatch. Even a missing trailing
/will fail witherror=invalid_request. The URI must be an exact byte-match against one of the App’sredirectUris. Add bothhttps://yourapp.com/api/oauth/callbackand the local one (e.g.http://localhost:3000/api/oauth/callback) for development. - Lost
stateorcode_verifier. Browsers will sometimes drop cookies on cross-site redirects. Use a cookie withSameSite=Lax(default) and persist your session before the redirect. - Hardcoded
audmismatch. Access tokens haveaud = <brand.slug>, not<client_id>. Verify against the brand slug. ID tokens haveaud = <client_id>. Different audiences for different tokens, on purpose. - Forgetting
openidinscope. Without it, you do not get anid_token. - Forgetting
offline_accessinscope. Without it, you do not get arefresh_token. - Reusing a refresh token. Single-use. After exchange, only the new one is valid.
- Verifying
client_secretin the browser. Never. SPAs and native apps are public clients. Use PKCE; the secret stays on your server. - Polling JWKS per request. Cache.
jose’screateRemoteJWKSetcaches by default with a 10-minute TTL. - Treating 401 as terminal. A 401 with
error="invalid_token"usually means the access token expired. Refresh and retry once before showing the user a sign-in page.