SSO
SSO
Enable social and OpenID Connect sign-in in VitNode with 15 built-in providers, including Apple, Google, Microsoft and GitLab, or with a custom OAuth2 adapter.
VitNode has built-in OAuth 2.0 and OpenID Connect single sign-on (SSO). Add a provider's adapter to your API config, and a sign-in button for that provider appears on /login, /register and Settings ā Connected accounts. This page walks you through your first provider, then explains the callback URL and how an SSO sign-in finds, links or creates an account.
Set up your first provider
Choose a provider
Pick one from the provider table and open its guide. The guide covers the provider's portal, scopes and quirks; this page covers the parts every provider shares.
Create an OAuth application
Create the app in the provider's developer portal, as its guide describes, and copy the client ID and client secret (Apple uses a key file instead, TikTok calls the ID a client key).
Register the callback URL
Give the provider the callback URL for each environment you sign in from, for example http://localhost:3000/login/sso/google for development and https://your-domain.com/login/sso/google for production. It must match VITNODE_WEB_URL exactly.
Add the credentials to the API's environment
Put the credentials in the .env of the app that serves the API. Where the files live tells you which app that is. They are server-side secrets, so never add them to clientEnv or to any variable the browser can read.
GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxxAdd the adapter to ssoAdapters
Open the same app's vitnode.api.config.ts. Import the adapter and add it to authorization.ssoAdapters. Leave plugins, dbProvider, email and the rest of the config as they are.
import { GoogleSSOApiPlugin } from "@vitnode/core/api/adapters/sso/google";
import { buildApiConfig } from "@vitnode/core/vitnode.config";
export const vitNodeApiConfig = buildApiConfig({
// ...
authorization: {
ssoAdapters: [
GoogleSSOApiPlugin({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
},
});Already have an authorization block, for example with passkeys? Add only ssoAdapters to it. Already have adapters? Append the new one to the same array:
authorization: {
passkeys: true,
ssoAdapters: [
DiscordSSOApiPlugin({
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
}),
GoogleSSOApiPlugin({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
},Restart the app
.env and vitnode.api.config.ts are read once at start-up. Stop the dev server and start it again from the project root (bun dev, pnpm dev or npm run dev), which restarts both apps in a Monorepo App.
Sign in for real
Open /login in a private window, press the Provider button under Continue with, sign in at the provider and allow access. You should land back on /login/sso/{providerId} and then be signed in as a new member. Sign out and do it again. This time the same provider account signs straight in.
Kick, Reddit and TikTok never give VitNode an email it can trust, and other providers sometimes don't. A new member from them must confirm an email, which needs an email adapter. Configure one before you test, or the first sign-in stops at We need a verified email.
Where the files live
The API config and the API's .env sit in the app that serves /api. Which app that is depends on the layout you chose in create-vitnode-app:
| Layout | API config | Server .env |
|---|---|---|
| Single App with Turborepo | apps/web/src/vitnode.api.config.ts | apps/web/.env |
| Single App without Turborepo | src/vitnode.api.config.ts | .env |
| Monorepo App (web + API) | apps/api/src/vitnode.api.config.ts | apps/api/.env |
- Single App. The web app mounts the API, so one
.envserves both. ThevitnodeCLI and VitNode's Vite plugin load it into the server'sprocess.env. OnlyVITNODE_API_URL,VITNODE_WEB_URLand the keys you list inclientEnvreach the browser. - Monorepo App. The API is its own service.
apps/api/src/vitnode.api.config.tsloadsapps/api/.envwithdotenv. Put SSO secrets andVITNODE_WEB_URLthere.apps/web/.envonly holdsVITNODE_API_URL, and its values can reach the browser, so it gets no secrets.
The generator creates .env.example files; copy them to .env as Setup shows. A real environment variable (Docker, Vercel, CI) wins over the same key in .env.
On the login page
The provider buttons sit in their own column next to the email form, under one Continue with heading, so each button only needs the provider's name (screen readers still hear "Continue with Google"). On small screens the columns stack: providers first, then the form.
/loginpacks the providers into a two-column grid, so six of them take three rows instead of six. The passkey button goes right below./registerstacks them full width, because the sign-up form next to them is taller anyway.- A Last used badge marks the method someone signed in with last, whether that's a provider, a passkey or a password. A last-used provider also moves to the top of the grid. The browser saves it only after a successful sign-in, so a visitor who gives up halfway through Discord's consent screen won't see it.
- Only one kind of method enabled? The card shrinks back to a single column.
The built-in adapters ship their own marks and, where the brand allows it, a filled button color. A custom adapter can add its own.
Providers
| Provider | Import | Email VitNode trusts | Local http://localhost |
|---|---|---|---|
| Apple | AppleSSOApiPlugin from .../sso/apple | With email_verified (may be a relay) | No, needs an HTTPS domain |
| Atlassian | AtlassianSSOApiPlugin from .../sso/atlassian | With email_verified | Yes |
| Discord | DiscordSSOApiPlugin from .../sso/discord | Always; unverified ones are refused | Yes |
FacebookSSOApiPlugin from .../sso/facebook | Always; no flag, but an email is required | Yes | |
| GitLab | GitLabSSOApiPlugin from .../sso/gitlab | With email_verified | Yes |
GoogleSSOApiPlugin from .../sso/google | Always; unverified ones are refused | Yes | |
| Kick | KickSSOApiPlugin from .../sso/kick | Never; confirmed by email | Yes |
LinkedInSSOApiPlugin from .../sso/linkedin | With email_verified | Not documented | |
| Microsoft | MicrosoftSSOApiPlugin from .../sso/microsoft | With xms_edov | Yes |
| PayPal | PayPalSSOApiPlugin from .../sso/paypal | With confirmed or email_verified | No, needs HTTPS |
RedditSSOApiPlugin from .../sso/reddit | Never; confirmed by email | Yes | |
| Slack | SlackSSOApiPlugin from .../sso/slack | With email_verified | HTTPS tunnel |
| TikTok | TikTokSSOApiPlugin from .../sso/tiktok | Never; confirmed by email | No, needs HTTPS |
| Twitch | TwitchSSOApiPlugin from .../sso/twitch | With email_verified | Yes |
| Twitter (X) | TwitterSSOApiPlugin from .../sso/twitter | confirmed_email, when shared | http://127.0.0.1 |
Every import path starts with @vitnode/core/api/adapters/sso/. "Refused" means the sign-in fails with an error instead of falling back to email confirmation. For any other provider, write a custom adapter.
Callback URLs
Register this redirect (callback) URL with the provider:
https://your-domain.com/login/sso/{providerId}VitNode builds it from VITNODE_WEB_URL with getRedirectUri(providerId), for example https://your-domain.com/login/sso/google. Without VITNODE_WEB_URL it is http://localhost:3000/login/sso/{providerId}. It must match exactly: same scheme and host, no trailing slash, no locale prefix. In a Monorepo App the API builds it, so set VITNODE_WEB_URL in apps/api/.env.
Providers that require a public HTTPS URL (Apple, PayPal, TikTok, and in practice Slack) need a tunnel for local work. Point VITNODE_WEB_URL (and VITNODE_API_URL, when the app serves its own API) at the tunnel's HTTPS address, and open the site there. The sign-in cookie belongs to the host the sign-in started on.
How accounts link
When the provider sends the visitor back, VitNode checks the state, asks the adapter for the provider account, then:
- Known identity. This provider account was linked before. The visitor is signed straight in, recognized by the provider's stable account ID. The email does not decide anything here, and an address stored earlier is kept even when the provider omits it this time.
- Trusted email, existing account. An account already uses that address. A matching email does not link anything by itself: the visitor is asked for that account's password, and only then is the provider account linked and the visitor signed in. From then on that provider signs them straight in (case 1).
- Trusted email, new visitor. A new account is created with the provider's display name and
emailVerified: true, and linked. Avatar and real name stay empty until the member imports them from Connected accounts. - No trusted email. The provider shared no address, or one the adapter does not trust. The visitor confirms an address by email first, then continues as in case 2 or 3.
Signed-in members can also connect more providers, import their avatar and name, and disconnect accounts in Settings ā Connected accounts. Connected accounts covers how that works.
When an email is trusted
OAuth success proves that the visitor controls the provider account, not the mailbox. Whether VitNode trusts the email depends only on what the adapter's fetchUser returns:
| Adapter returns | Email trusted? | A new identity |
|---|---|---|
email and emailVerified: true | Yes | Signs up, or is offered a link to the matching account |
email, emailVerified omitted | Yes | Same as true |
email and emailVerified: false | No | Confirms an email; the address only pre-fills the form |
email: null or no email | No email | Confirms an email |
emailVerified: true is the adapter's claim that the provider verified the address. An omitted emailVerified is treated like true, so adapters written before the field existed keep working. That makes leaving it out a promise too, so only do it when the provider never returns an unconfirmed address. Each built-in adapter sets the field from the provider's own flag, listed in the provider table. An empty address, or one longer than 255 characters, counts as no email.
SSO confirms the email
A trusted provider email doubles as email confirmation:
- a new visitor starts out with
emailVerified: true, even when an email adapter is configured and password sign-ups would have to confirm first; - a known identity or a freshly linked account whose address is still unconfirmed is marked confirmed, as long as the trusted address the provider returned is the one on the account. An account whose email was changed since the link was made is left alone.
An untrusted address never confirms anything.
Linking an existing account
The callback answers 409 with a short-lived, signed link offer. It holds the account's email, whether that account has a password, and a token good for ten minutes. The login page turns that into a "Connect Provider to your account" form: the email is shown read-only, the visitor types their password, and POST /users/sso/{providerId}/link verifies both before it writes the core_users_sso row and mints a session. The token carries the provider account ID, so nothing about which identity gets linked is taken from the browser.
An account created by another provider has no password to confirm with. When an email adapter is configured the form offers Set a password (the ordinary reset flow) and asks them to try the provider again afterwards; without one it points back to the login page.
Every successful link emits user.sso.linked.
The address the provider returns is matched in its canonical form, so a Google account that reports jan.kowalski@gmail.com finds the member who registered as jankowalski@gmail.com instead of quietly becoming a second account. See One mailbox, one account.
The sign-in round trip
Pressing a provider button calls POST /users/sso/{providerId}. VitNode stores a sign-in attempt in core_sso_attempts: hashes of a random state and of a browser-binding secret, plus a PKCE verifier and an OpenID Connect nonce. The browser gets only the binding secret, in an HttpOnly SameSite=Lax cookie.
The callback consumes the attempt once. It is refused with 400 when it is unknown, older than ten minutes, already used, started in another browser, or started for another provider. The adapter then receives the PKCE verifier and nonce for the token exchange and ID-token checks; neither ever reaches the browser.
Apple returns with a cross-site POST. VitNode's Start middleware turns it into a same-URL GET so the attempt is checked like any other. Apple has the details.