Synq Docs
Guides

Sign in from your own UI

Sign people in with a password, an emailed code or a passkey on your app's own screens, without sending them to the hosted page.

By default, people sign in on your brand's hosted page. A first-party app can instead sign them in on its own screens. Synq still checks every password, code and passkey, applies the same rules and limits as the hosted page, and issues the same tokens; your app only collects what the person types.

It follows OAuth 2.0 for First-Party Applications (draft 04): your app's server, or your native app, talks to the issuer's Authorization Challenge Endpoint, and redeems the code it ends with at the token endpoint.

Which apps can

  • First-party web apps, from their server. With @synqauth/nextjs, that's the route mount you already have.
  • First-party native apps, from the device.

Single-page apps can't: there's no server to keep the sign-in from the page. Use a web app with @synqauth/nextjs instead. Third-party apps, apps an MCP host registered, machine-to-machine apps and agents can't either, since the person would be typing their password into someone else's code.

Your app's code sees the passwords and codes people type, so turn this on only for apps your company builds. Passkeys can't be phished; passwords and codes can.

Turning it on

In the dashboard, open the app and turn on Sign in from this app's own screens. It needs the org.apps.edit permission and a first-party web or native app. Through the management API, set own_ui_sign_in: true when you update the app. Turning first_party off turns it off too.

While it's off, Synq answers every own-UI sign-in with "go to the hosted page", and the SDKs go there by themselves, so turning it off never breaks a sign-in. Synq can pause it for every app the same way.

Next.js and React

@synqauth/nextjs's route mount takes each step at POST /api/auth/sign-in/flow and calls Synq as your app; the page only hears what to show next, never a token, a code or Synq's auth session. <SignInForm> from @synqauth/react is the whole form:

// app/sign-in/page.tsx
'use client'

import '@synqauth/react/styles.css'
import { SignInForm } from '@synqauth/react'

export default function SignInPage() {
  return <SignInForm returnTo="/dashboard" />
}

It offers a passkey first where the browser can use the brand's passkeys on your page (see Passkeys), including in the address field as the person types. Then it offers an emailed code or a password, a sign-up screen, and the new address's code. "Forgot your password?" goes to the hosted page, where resetting lives. methods={['email']} offers fewer ways in, and screen="sign-up" opens on sign-up.

To lay the page out your own way, use its parts over one useSignIn():

const signIn = useSignIn({ returnTo: '/dashboard' })

<PasskeyButton signIn={signIn} />
{signIn.step === 'code' ? <EmailCodeForm signIn={signIn} /> : <PasswordForm signIn={signIn} emailCode />}
{signIn.step === 'verify-email' && <VerifyEmailForm signIn={signIn} />}

Or build the form yourself from useSignIn(): step is start, code, verify-email or done, problem.code says what went wrong (branch on it, never on the English), and password, emailCode, passkey and verifyEmail take each step. The words are in <SynqProvider messages>, signInProblems among them.

Telling Synq where people are

Synq limits tries per address, as on the hosted page, but only your server knows the person's address. The route mount sends it as Synq-End-User-IP, from the x-real-ip header by default, which Vercel sets. Elsewhere, read a header your own proxy sets, never one the browser could send:

export const synqConfig = defineSynqConfig({
  clientAddress: (request) => request.headers.get('cf-connecting-ip') ?? undefined,
})

Without an address, per-address limits are off for your app (@synqauth/nextjs warns once in its log). The address counts only toward your app's own limits, so it can't lock anyone out of another app.

React Native

useSignIn() from @synqauth/react-native signs in from the device itself, as a public client:

const signIn = useSignIn()

await signIn.password.signIn({ email, password })
// or
await signIn.emailCode.send(email)
await signIn.emailCode.verify(code)

The provider follows the session it ends with. Passkeys, and anything else only the hosted page can do, go on in the system browser: when signIn.handover is set, call signIn.continueInBrowser().

Passkeys

A passkey belongs to the issuer's host, so the browser offers it on your app's page only when the issuer lists your page's origin as a related origin (its /.well-known/webauthn) and the browser supports related origins: Chrome and Edge 128+, Safari 18+ and Firefox 152+. Synq lists your app's redirect origins there, and the SDKs check the browser first and use the hosted page where it can't.

Browsers read only the first 5 sites (registrable domains, such as acme.com) of the list. Synq lists the brand's custom domain first, then the apps with own-UI sign-in, oldest first, then the brand's other first-party web apps. The dashboard shows, for each of the app's origins, whether passkeys work on it or go to the hosted page, and the management API warns passkey_origin_beyond_related_origins_limit for one past the 5th site.

On a custom domain, passkeys made at the brand's own Synq host before the domain existed work too: <SignInForm> offers them with a second button.

Native apps use passkeys on the hosted page, in the system browser. Signing up with a passkey also stays on the hosted page.

What stays on the hosted page

Synq answers "go to the hosted page" (redirect_to_web) for anything only it can do, and the SDKs go there with the method and the address already chosen:

synq_reasonWhy
own_ui_off, pausedThe app's switch is off, or Synq paused own-UI sign-in.
token_gateThe app has a token gate: the person connects a wallet there.
step_upThe app asked for a passkey sign-in (acr_values or claims with urn:synq:loa:2), which can't go on here.
passkey_unavailableA passkey can't run on this app's screens: a native app, an origin past the 5th site, or a passkey sign-up.
method_unavailableThe brand doesn't offer that way in.
code_lockedToo many wrong codes for the address: typed codes are paused, and the hosted page's emailed link still works.
email_in_useAnother account already proved the address.
sign_up_gateSynq's sign-up rules need the hosted page for this new account.
unproven, hosted_onlyAnything else only the hosted page asks for first.

Linking another way to sign in, wallets, social accounts, resetting a password and signing up with a passkey all happen there too. Adding a passkey after an own-UI sign-in works from your app through the account API: start making one, then keep it.

Signing out

A session signed in on your app's own screens has no browser session at the issuer, so it says via: 'app' (in the SDK's session and the account API's list of sessions). Signing out revokes its refresh token, with no trip to the issuer's sign-out page.

The protocol

The SDKs do all of this for you. For any other server or language, the issuer's discovery document names the endpoint as authorization_challenge_endpoint: POST https://<issuer>/authorize-challenge.

Send a form (application/x-www-form-urlencoded, at most 16 KB) from your server or native code, with your app's client authentication as at the token endpoint. A request carrying Origin or Sec-Fetch-Site, which only browsers send, is refused. A web app's server adds Synq-End-User-IP: <the person's address>.

The first request starts the sign-in:

Parameter
response_type=codeRequired.
code_challenge, code_challenge_method=S256Required: PKCE, always.
scopeMust include openid.
resource, claims, acr_values, nonceAs in an authorization request.
synq_methodpassword, email or passkey. Without it, the answer lists the app's methods in synq_methods.
synq_screensign-in (the default) or sign-up.
usernameThe email address.
passwordWith synq_method=password.

redirect_uri, request, request_uri, response_mode, max_age and id_token_hint are refused. Each later request sends auth_session from the last answer, with one of:

  • password (with username), or otp, the 6-digit code
  • synq_passkey, the browser's PublicKeyCredential.toJSON() as JSON, with synq_passkey_rp=related for the brand's own host's passkey on a custom domain
  • synq_action=resend, or synq_action=skip to leave a new address unverified where the brand allows it
  • synq_method=<another>, to switch methods

Every answer is JSON, never cached:

StatuserrorWhat to do
200Redeem authorization_code at the token endpoint with grant_type=authorization_code and your code_verifier, without redirect_uri.
403insufficient_authorizationAsk for what synq_next names (method, password, otp, verify_email or passkey), then send it with auth_session. It may carry synq_problem, synq_methods, synq_code_expires_in, synq_can_skip, synq_passkey_options and synq_passkey_related_options.
403redirect_to_webStart an ordinary authorization request, with synq_method and login_hint from the answer. synq_reason says why (see above).
400access_deniedThis person can't sign in here: synq_reason is disabled, deleting, sign_up_closed or already_exists.
400invalid_sessionThe sign-in ended: start again.
400invalid_request, invalid_scope, invalid_target, unauthorized_clientFix the request, or the app isn't allowed.
401invalid_clientClient authentication failed.
429too_many_requestsWait Retry-After seconds. synq_problem is slow_down, locked or limited, and the sign-in goes on.

synq_problem is one of missing, invalid_email, wrong_credentials (the same for an unknown address and a wrong password), email_taken, password_too_short, password_too_long, password_guessable, password_breached, wrong_code, code_expired, code_used_up, address_suppressed, team_only, send_failed, passkey_failed, passkey_unknown, passkey_expired, slow_down, locked or limited. @synqauth/js exports the lists as CHALLENGE_PROBLEMS, REDIRECT_REASONS and DENIED_REASONS.

An auth session lasts 15 minutes from its first request, and ends after 20 requests, 5 wrong passwords, 3 emailed codes or 5 failed passkeys. Synq also limits requests per address and per app: see challenge.ip, challenge.client and client_auth.ip in the rate limits.

On this page