Documentation

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.

apps/web/.env (Single App with Turborepo)
GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx

Add 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.

apps/web/src/vitnode.api.config.ts (excerpt)
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:

apps/web/src/vitnode.api.config.ts (excerpt)
  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:

LayoutAPI configServer .env
Single App with Turborepoapps/web/src/vitnode.api.config.tsapps/web/.env
Single App without Turboreposrc/vitnode.api.config.ts.env
Monorepo App (web + API)apps/api/src/vitnode.api.config.tsapps/api/.env
  • Single App. The web app mounts the API, so one .env serves both. The vitnode CLI and VitNode's Vite plugin load it into the server's process.env. Only VITNODE_API_URL, VITNODE_WEB_URL and the keys you list in clientEnv reach the browser.
  • Monorepo App. The API is its own service. apps/api/src/vitnode.api.config.ts loads apps/api/.env with dotenv. Put SSO secrets and VITNODE_WEB_URL there. apps/web/.env only holds VITNODE_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.

  • /login packs the providers into a two-column grid, so six of them take three rows instead of six. The passkey button goes right below.
  • /register stacks 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

ProviderImportEmail VitNode trustsLocal http://localhost
AppleAppleSSOApiPlugin from .../sso/appleWith email_verified (may be a relay)No, needs an HTTPS domain
AtlassianAtlassianSSOApiPlugin from .../sso/atlassianWith email_verifiedYes
DiscordDiscordSSOApiPlugin from .../sso/discordAlways; unverified ones are refusedYes
FacebookFacebookSSOApiPlugin from .../sso/facebookAlways; no flag, but an email is requiredYes
GitLabGitLabSSOApiPlugin from .../sso/gitlabWith email_verifiedYes
GoogleGoogleSSOApiPlugin from .../sso/googleAlways; unverified ones are refusedYes
KickKickSSOApiPlugin from .../sso/kickNever; confirmed by emailYes
LinkedInLinkedInSSOApiPlugin from .../sso/linkedinWith email_verifiedNot documented
MicrosoftMicrosoftSSOApiPlugin from .../sso/microsoftWith xms_edovYes
PayPalPayPalSSOApiPlugin from .../sso/paypalWith confirmed or email_verifiedNo, needs HTTPS
RedditRedditSSOApiPlugin from .../sso/redditNever; confirmed by emailYes
SlackSlackSSOApiPlugin from .../sso/slackWith email_verifiedHTTPS tunnel
TikTokTikTokSSOApiPlugin from .../sso/tiktokNever; confirmed by emailNo, needs HTTPS
TwitchTwitchSSOApiPlugin from .../sso/twitchWith email_verifiedYes
Twitter (X)TwitterSSOApiPlugin from .../sso/twitterconfirmed_email, when sharedhttp://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.

When the provider sends the visitor back, VitNode checks the state, asks the adapter for the provider account, then:

  1. 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.
  2. 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).
  3. 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.
  4. 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 returnsEmail trusted?A new identity
email and emailVerified: trueYesSigns up, or is offered a link to the matching account
email, emailVerified omittedYesSame as true
email and emailVerified: falseNoConfirms an email; the address only pre-fills the form
email: null or no emailNo emailConfirms 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.