Skip to Content
Quickstart

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.

For AI agents: the /llms.txt file 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/js
import { 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:

  1. An Organization, a Brand, and an App registered in the Synq Dashboard.
  2. Sign-in working in your product against https://synq.id.
  3. A backend that verifies access tokens using the JWKS endpoint.
  4. 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 a client_secret), spa (browser-only, public, PKCE), native (mobile, public, PKCE), m2m (machine-to-machine), or agent (CLI / AI agent using the device flow).
  • Allowed grants: authorization_code and refresh_token cover 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 POST shapes.

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 your client_id. Carries the user’s identity claims (sub, email, given_name, etc.). Verify nonce matches 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 with error=invalid_request. The URI must be an exact byte-match against one of the App’s redirectUris. Add both https://yourapp.com/api/oauth/callback and the local one (e.g. http://localhost:3000/api/oauth/callback) for development.
  • Lost state or code_verifier. Browsers will sometimes drop cookies on cross-site redirects. Use a cookie with SameSite=Lax (default) and persist your session before the redirect.
  • Hardcoded aud mismatch. Access tokens have aud = <brand.slug>, not <client_id>. Verify against the brand slug. ID tokens have aud = <client_id>. Different audiences for different tokens, on purpose.
  • Forgetting openid in scope. Without it, you do not get an id_token.
  • Forgetting offline_access in scope. Without it, you do not get a refresh_token.
  • Reusing a refresh token. Single-use. After exchange, only the new one is valid.
  • Verifying client_secret in the browser. Never. SPAs and native apps are public clients. Use PKCE; the secret stays on your server.
  • Polling JWKS per request. Cache. jose’s createRemoteJWKSet caches 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.
Last updated on