Add SkyCanvas SSO to your application
Pick the app you are integrating. The page will show only the setup that applies to it.
What kind of app are you adding SSO to?
Choose the closest match. You can switch without losing anything.
Selected guide
Full-stack standalone
Install
Add the SSO package
Connect server
Configure and mount auth
Use session
Sign in, protect, sign out
React applications on this standalone path must mount one SsoProvider. Pass it the bootstrap returned by the server helper so it receives the initial session and creates its browser client without another configuration file.
Architecture
Choose who owns the application session
SkyCanvas always owns central identity and upstream OAuth callbacks. Your integration choice decides how the consuming application proves authentication on later requests.
| Integration | Best for | Session owner | App credential | New auth server? |
|---|---|---|---|---|
| React-only | Vite/SPA frontend with a separate API or no backend | SkyCanvas SDK in the browser | Short-lived app access token | No |
| Full-stack standalone | TanStack Start, Next.js, Elysia, Express, or NestJS | @skycanvasstudio/sso server adapter | Encrypted first-party HttpOnly cookie | Small framework adapter |
| Better Auth | The app already has working Better Auth | Better Auth | Better Auth first-party cookie | Existing Better Auth route |
| Generic OAuth/OIDC | Another auth library already owns app sessions | Your existing auth library | Library-owned cookie/session | Existing auth callback |
Selected guide
Use SSO in a full-stack TypeScript app
Complete these steps in order. OAuth tokens and session secrets stay on the server for this integration.
- 1
Install one package
No Better Auth package, auth database, or consumer-side OAuth plugin is required.
Terminalbun add @skycanvasstudio/sso - 2
Server environment (required)
Put these values in your server-only environment module. APP_URL is the public origin of this app and prevents a container, proxy, or server bound to 0.0.0.0 from generating an invalid callback. SKYCANVAS_SECRET_KEY must never reach browser code.
.env — server only# Server only — do not prefix these with VITE_ or NEXT_PUBLIC_ SKYCANVAS_PUBLISHABLE_KEY=your_client_id SKYCANVAS_SECRET_KEY=replace_with_at_least_32_random_characters SKYCANVAS_SSO_URL=https://api-sso.skycanvasstudio.com APP_URL=http://localhost:3000 - 3
Client environment (not needed)
The packaged UI gets its safe configuration from the server bootstrap and local auth routes. Do not create VITE_SKYCANVAS_* or NEXT_PUBLIC_SKYCANVAS_* variables—not even for the publishable key.
No client .env entryNo browser environment variables are required. - 4
Configure SkyCanvas once
Choose embedded forms or the hosted page here. Popup is recommended for social OAuth; redirect remains available. The SDK provides an immediate popup loading screen while it prepares and opens SkyCanvas, so no application popup route is needed.
src/lib/skycanvas.server.tsimport { createTanStackSso } from "@skycanvasstudio/sso/tanstack-start" import { env } from "./env.server" export const skycanvas = createTanStackSso({ publishableKey: env.SKYCANVAS_PUBLISHABLE_KEY, secretKey: env.SKYCANVAS_SECRET_KEY, ssoUrl: env.SKYCANVAS_SSO_URL, appUrl: env.APP_URL, interactionMode: "embedded", // "hosted" redirects to the SSO auth page oauthMode: "popup", // or "redirect" }) - 5
Add one middleware
It mounts the package auth routes and makes the verified session available as context.skycanvasAuth.
src/start.tsimport { createServerOnlyFn, createStart } from "@tanstack/react-start" import { createTanStackSsoMiddleware } from "@skycanvasstudio/sso/tanstack-start" const loadSkycanvas = createServerOnlyFn( () => import("./lib/skycanvas.server").then(({ skycanvas }) => skycanvas), ) const skycanvasMiddleware = createTanStackSsoMiddleware( loadSkycanvas, ) export const startInstance = createStart(() => ({ requestMiddleware: [skycanvasMiddleware], })) - 6
Optional: receive user webhooks
This is separate from app/auth/[...sso]/route.ts. Configure the webhook once for the SkyCanvas application through the SSO admin API: PUT /admin/applications/:applicationId/webhooks. This is application-level delivery configuration, not an OAuth client setting and not an option passed to createNextSso(). Save the one-time returned secret as SSO_WEBHOOK_SECRET in this app's server environment.
app/api/sso/webhooks/route.tsimport { createWebhookHandler } from "@skycanvasstudio/sso/server" import { env } from "@/env" import { prisma } from "@/lib/prisma" export const POST = createWebhookHandler( { "user.created": async ({ data }) => { await prisma.user.upsert({ where: { ssoUserId: data.id }, create: { ssoUserId: data.id, name: data.name, email: data.email, image: data.image }, update: { name: data.name, email: data.email, image: data.image }, }) }, "user.updated": async ({ data }) => { await prisma.user.upsert({ where: { ssoUserId: data.id }, create: { ssoUserId: data.id, name: data.name, email: data.email, image: data.image }, update: { name: data.name, email: data.email, image: data.image }, }) }, "user.deleted": async ({ data }) => { await prisma.user.deleteMany({ where: { ssoUserId: data.id } }) }, }, { secret: env.SSO_WEBHOOK_SECRET }, ) - 7
Repair a missed webhook on sign-in
Webhooks keep an app synchronized while the user is away. Add onSignIn as a recovery path: it runs after the SSO identity is verified and before the local application session is created. Always use a unique ssoUserId column; do not assume the local primary key is the SSO user ID.
src/lib/skycanvas.tsexport const skycanvas = createNextSso({ // publishableKey, secretKey, ssoUrl, and appUrl from the earlier step onSignIn: async ({ user }) => { const localUser = await prisma.user.upsert({ where: { ssoUserId: user.id }, create: { ssoUserId: user.id, name: user.name, email: user.email, image: user.image }, update: { name: user.name, email: user.email, image: user.image }, }) return { ...user, localUserId: localUser.id } }, }) - 8
Use the packaged UI anywhere
SignIn, SignUp, SsoAuth, and SsoAuthDialog automatically show only enabled methods. After successful password or popup OAuth authentication, returnTo safely navigates the original window. Provide onSuccess when your app should control navigation itself.
src/routes/login.tsximport "@skycanvasstudio/sso/styles.css" import { SignIn, SkyCanvasProvider } from "@skycanvasstudio/sso/react" export function LoginPage() { return ( <SkyCanvasProvider> <SignIn returnTo="/dashboard" /> </SkyCanvasProvider> ) } - 9
Protect server data
UI components improve navigation, but authorization must be enforced where protected data is loaded.
src/routes/protected.tsximport { createFileRoute, redirect } from "@tanstack/react-router" export const Route = createFileRoute("/protected")({ beforeLoad: ({ context }) => { if (!context.skycanvasAuth.isAuthenticated) throw redirect({ to: "/login" }) }, component: ProtectedPage, }) - 10
Register the app once in SkyCanvas
Add your app origin and callback in SkyCanvas. Do not add this callback—or each consumer domain—to Google, GitHub, Facebook, or LinkedIn; those providers keep pointing only to New SSO.
SkyCanvas dashboard (not a project file)Allowed origin: http://localhost:3000 Callback URL: http://localhost:3000/auth/callback
React SDK
Components, hooks, and ready-made account UI
These APIs are available below SkyCanvasProvider in standalone integrations. Better Auth integrations keep using Better Auth's own session hooks and components.
<SkyCanvasProvider />Connect a React-only app with its publishable key and SSO URL. Full-stack apps instead give SsoProvider its server bootstrap.
<SignIn /> / <SignUp />Ready-made embedded password, magic-link, and OAuth forms. Only OAuth buttons open provider popups.
<SsoAuth /> / <SsoAuthDialog />The configurable auth form and its dialog wrapper when you need a custom sign-in or sign-up entry point.
<SsoSignInButton />A small ready-made sign-in button for a header, hero, or any place a full auth form does not fit.
<SignedIn /> / <SignedOut />Conditionally render UI after the provider has resolved authentication state.
useAuth()Read isLoaded, isSignedIn, userId, session, getToken, and signOut.
useUser()Read the verified SkyCanvas user and loading/authentication state.
<UserProfile />One complete profile component with dialog and content modes, read-only OAuth avatar, email capability warnings, connected accounts, and active sessions.
<SsoUserMenu />Ready-made account menu that opens UserProfile in dialog mode, supports custom links, and handles logout.
createSsoAccessTokenVerifier()Cache public metadata/JWKS and verify React-only Bearer tokens in an application API.
Add a complete account menu and profile
UserProfile is the single profile implementation. SsoUserMenu opens it in dialog mode, while a profile route can render the same component in content mode. OAuth images stay read-only, account deletion is intentionally absent, and email actions clearly explain when the application needs a connected mail provider.
import {
SignIn,
SignedIn,
SignedOut,
SsoUserMenu,
UserProfile,
} from "@skycanvasstudio/sso/react"
export function AccountMenu() {
return (
<>
<SignedOut>
<SignIn returnTo="/dashboard" />
</SignedOut>
<SignedIn>
<SsoUserMenu
items={[{ label: "Dashboard", href: "/dashboard" }]}
logoutReturnTo="/"
/>
<UserProfile mode="content" />
</SignedIn>
</>
)
}
// Or use <UserProfile mode="dialog" label={<ProfileIcon />} />.
// The label accepts text, an icon, or both.
// additionalContent can add one minimal app-specific section.SignedIn protects presentation, not data. Always enforce authentication again in the server loader, route handler, or API that returns protected information.
Agent handoff
Give any coding agent the complete context
Copy this file into the target project or paste it into an agent task. It tells the agent to use the package, select one auth path, preserve the existing session system, and verify the result.
# SkyCanvas SSO implementation guide for coding agents
Use the published `@skycanvasstudio/sso` package as a normal consumer. Do not
import package source files or copy OAuth, cookie, or token-verification logic
into the application.
## First choose one session owner
1. **React-only public client**: the SkyCanvas browser SDK owns the local
short-lived token session; the application API verifies Bearer tokens.
2. **Better Auth**: Better Auth owns users, accounts, callback handling, cookies,
and sessions.
3. **Another auth library**: that library owns the callback and session; use
SkyCanvas provider metadata only.
4. **Full-stack without an auth library**: `createSsoServer` owns the OAuth flow and encrypted local
application session.
5. **Non-JavaScript backend**: use a maintained OAuth 2.0/OIDC library; do not
install the npm package.
Never combine Better Auth session hooks with the standalone `SsoProvider`.
## Environment rule
Session secrets and full-stack configuration belong in the application's
server-only env module. Pass explicit values to SkyCanvas exactly once and never
pass the complete environment object. A React-only public client intentionally
uses `VITE_SKYCANVAS_PUBLISHABLE_KEY` and `VITE_SKYCANVAS_SSO_URL`; the
publishable key is an identifier, not a secret.
## React-only path
Use this for a Vite/SPA application that should not run an auth callback server.
Register the exact frontend origin and `{APP_ORIGIN}/auth/callback`. The host
must serve the SPA entry at `/auth/callback`.
```tsx
import { SkyCanvasProvider } from "@skycanvasstudio/sso/react"
import "@skycanvasstudio/sso/styles.css"
<SkyCanvasProvider
publishableKey={import.meta.env.VITE_SKYCANVAS_PUBLISHABLE_KEY}
ssoUrl={import.meta.env.VITE_SKYCANVAS_SSO_URL}
>
<App />
</SkyCanvasProvider>
```
Use `SignIn`, `SignedIn`, `SignedOut`, `useAuth`, and `useUser`. Send the result
of `useAuth().getToken()` as a Bearer token only to the intended application
API. Create one `createSsoAccessTokenVerifier()` in that API process and verify
each protected request. Treat `SignedIn` as presentation control, not backend
authorization. Do not add `createSsoServer`, Elysia, a client secret, or a second
callback to this path.
Use the same packaged profile UI in either form:
```tsx
import { UserProfile } from "@skycanvasstudio/sso/react"
<UserProfile mode="dialog" label="Profile" />
<UserProfile mode="content" />
```
The dialog `label` accepts any React node, including text, an icon, or both.
`UserProfile` keeps the OAuth avatar read-only and provides name updates, email
verification, password set/reset when enabled, connected-account management,
and active-session controls. It has no account-deletion action. Use the single
`additionalContent` slot only for a small application-specific section.
Profile requests use the same short-lived, application-scoped access token.
The browser never receives OAuth-provider credentials, mail-provider secrets,
or file-server credentials. If the application has no active mail connection,
the profile shows a warning and disables verification/password-email actions.
`SignIn` must remain embedded and show the password, magic-link, and social
methods returned by the application's public metadata. Password and magic-link
forms call central SkyCanvas directly. Only social provider buttons open a
popup, with the selected provider passed so the popup continues into that
provider rather than showing a second generic SkyCanvas login page.
## Better Auth path
Before SkyCanvas work, verify Better Auth installation, database adapter,
generated schema, migrations, server handler, browser client, and normal
sign-in/session behavior using Better Auth's official documentation.
Server configuration (`src/lib/auth.ts`):
```ts
import { skycanvas } from "@skycanvasstudio/sso/better-auth"
import { betterAuth } from "better-auth"
import { env } from "./env.server"
export const auth = betterAuth({
// Preserve the existing database, plugins, and options.
account: { encryptOAuthTokens: true },
plugins: [skycanvas({
publishableKey: env.SKYCANVAS_PUBLISHABLE_KEY,
ssoUrl: env.SKYCANVAS_SSO_URL,
})],
})
```
For TanStack Start, `tanstackStartCookies()` must remain the final plugin in
the array. The SSO server uses a distinct cookie prefix, so a local service app
and local SSO server cannot overwrite each other's OAuth state cookies merely
because both use the `localhost` hostname.
Mount `auth.handler` only through Better Auth's normal framework route. Register
exactly `{BETTER_AUTH_URL}/api/auth/oauth2/callback/skycanvas`. Do not create a
second callback or standalone `createSsoServer` instance.
Browser integration (`src/lib/auth-client.ts`):
```ts
import { skycanvasClient } from "@skycanvasstudio/sso/better-auth"
import { createAuthClient } from "better-auth/react"
export const authClient = createAuthClient({ plugins: [skycanvasClient()] })
export const signInWithSkyCanvas = (callbackURL = "/dashboard") =>
authClient.signIn.oauth2({ providerId: "skycanvas", callbackURL })
```
Keep using Better Auth's existing session hooks, provider, user types, route,
and sign-out behavior. Do not add a second SkyCanvas provider or bootstrap
layer to the React tree.
## Another auth library
Call `createSsoProvider({ publishableKey: env.SKYCANVAS_PUBLISHABLE_KEY, ssoUrl: env.SKYCANVAS_SSO_URL })`
on the server and map its endpoints into the existing library. The library must
use Authorization Code, PKCE S256, state, nonce, server-side token exchange,
JWKS signature verification, issuer, audience, expiry, and subject validation.
Keep its own callback, session, user types, and logout behavior.
## Standalone path
For TanStack Start, configure the adapter once:
```ts
import { createTanStackSso } from "@skycanvasstudio/sso/tanstack-start"
import { env } from "./env.server"
export const skycanvas = createTanStackSso({
publishableKey: env.SKYCANVAS_PUBLISHABLE_KEY,
secretKey: env.SKYCANVAS_SECRET_KEY,
ssoUrl: env.SKYCANVAS_SSO_URL,
})
```
Mount its middleware once:
```ts
import { createServerOnlyFn, createStart } from "@tanstack/react-start"
import { createTanStackSsoMiddleware } from "@skycanvasstudio/sso/tanstack-start"
const load = createServerOnlyFn(() =>
import("./lib/skycanvas.server").then(({ skycanvas }) => skycanvas),
)
export const startInstance = createStart(() => ({
requestMiddleware: [createTanStackSsoMiddleware(load)],
}))
```
For Next.js use `createNextSso()` with the same three values and export its
`GET`, `POST`, and `OPTIONS` handlers from `app/auth/[...sso]/route.ts`. The SDK
infers the public app origin and `/auth/callback` URL from the request. Set
`appUrl` only when a proxy does not forward the original host and protocol.
Mount `SkyCanvasProvider` once and use the packaged `SignIn`, `SignedIn`,
`SignedOut`, `useAuth`, `SsoUserMenu`, and `UserProfile` APIs. Register
`{APP_ORIGIN}/auth/callback` in SkyCanvas.
`SsoUserMenu` uses `UserProfile mode="dialog"` internally. Render
`<UserProfile mode="content" />` on a dedicated profile page, or render
`<UserProfile mode="dialog" label={...} />` for a custom trigger. The component
provides the same read-only OAuth avatar, name, verification, password,
connected-account, active-session, mail-capability, and minimal extension
behavior described in the React-only path. It intentionally excludes account
deletion and avatar upload.
## User webhooks for full-stack applications
User webhooks are optional application-level delivery configuration. They are
not OAuth client configuration and are not passed to `createNextSso()`,
`createTanStackSso()`, or the `/auth/[...sso]` route. Configure one endpoint
per SkyCanvas application with `PUT /admin/applications/:applicationId/webhooks`.
The response returns the generated secret only when the endpoint is first
created or rotated; store it as server-only `SSO_WEBHOOK_SECRET` in that
application.
Mount a separate `POST` route such as `/api/sso/webhooks`; never put it inside
`app/auth/[...sso]/route.ts`. Use `createWebhookHandler()` from
`@skycanvasstudio/sso/server`, keep receiver changes idempotent with an upsert
using a unique `ssoUserId`, and delete by that column for `user.deleted`.
```ts
import { createWebhookHandler } from "@skycanvasstudio/sso/server"
export const POST = createWebhookHandler(
{
"user.created": ({ data }) => prisma.user.upsert(/* keyed by data.id */),
"user.updated": ({ data }) => prisma.user.upsert(/* keyed by data.id */),
"user.deleted": ({ data }) => prisma.user.deleteMany({ where: { ssoUserId: data.id } }),
},
{ secret: process.env.SSO_WEBHOOK_SECRET! },
)
```
Webhooks are eventually delivered, so add `onSignIn` to the full-stack SDK
configuration as a repair path. It receives the verified SSO user before the
local session is written; upsert the same `ssoUserId` there. This repairs a
client database user that is missing because a prior webhook or local write
failed.
The standalone handler must receive both `GET` and `POST /auth/user-profile`.
Mounting the documented `/auth/*` catch-all or the Next/TanStack adapters does
this automatically. The adapter keeps the app access token sealed in the
application's encrypted HttpOnly cookie and proxies profile operations to
SkyCanvas; do not expose that token through bootstrap data or a JSON session
endpoint. After upgrading an existing integration to a version that adds this
route, sign in once again so the local session contains the required profile
authorization data.
## Required verification
- Missing values and invalid URLs produce actionable configuration errors.
- The bootstrap is plain serializable data and contains no secrets or functions.
- A returning SSR session renders authenticated without a flash or initial
profile request.
- New-user login, returning-user login, callback rejection, protected routes,
local/global logout, and safe local return paths work.
- TanStack never imports or returns the SSO server object through a server
function; only the bootstrap crosses the boundary.
- Better Auth, generic-library, and full-stack standalone flows keep OAuth
tokens, flow state, nonce, verifier, and session secrets server-only.
- The React-only flow keeps PKCE state and the short-lived application token in
the SDK-managed browser session; protected APIs verify every Bearer token and
no session secret exists in the frontend.
- `UserProfile` works in both dialog and content modes; full-stack profile
requests reach `GET` and `POST /auth/user-profile` without returning the
sealed access token to React.
- OAuth avatars remain read-only, account deletion is absent, and email-driven
actions are disabled with a clear warning when the application has no active
mail-provider connection.
- Webhook endpoints are configured once per SkyCanvas application, not in an
OAuth client or browser configuration. The receiver route is separate from
`/auth/[...sso]`, verifies `SSO_WEBHOOK_SECRET`, handles every event
idempotently, and `onSignIn` repairs a missed delivery.
Common problems
Troubleshooting
redirect_uri is invalid or login returns 403
Copy the exact callback URL from the selected guide into the application client. Paths, ports, schemes, and trailing slashes must match.
React popup signs in but the opener never updates
Make /auth/callback load the same SPA entry and register the frontend origin. Do not proxy that route to a removed local auth server.
Token exchange fails from a React app
Add the exact frontend origin to Allowed origins and confirm OAuth token issuance is enabled on the SkyCanvas deployment.
Session disappears after callback in a full-stack app
Use HTTPS in production, keep appUrl equal to the public app origin, and verify proxy forwarded host/protocol headers and cookie SameSite settings.
Hooks say the provider is missing
Mount exactly one provider above the route tree and use hooks from the same integration path. Do not mix Better Auth-generated hooks with standalone hooks.
Protected UI works but API requests are still public
SignedIn only controls rendering. Verify the Bearer token in a React-only app API, or read the verified server session in a full-stack app before returning protected data.
Before production
Security checklist
- Choose one integration path and do not create a second session system.
- Register the exact callback URL produced by your selected integration path.
- Use HTTPS for every production app, API, callback, and SSO origin.
- Keep session secrets server-only; never put them in VITE_ or NEXT_PUBLIC_ variables.
- Use an HttpOnly application cookie for full-stack apps; use only short-lived verified tokens in React-only apps.
- Validate issuer, audience, signature, expiry, and subject on every protected API token.
- Allow only relative return paths and use exact allowed origins.
- Test new users, returning users, logout, invalid callbacks, and protected routes.