Skip to main content
Vertz ships factory functions for OAuth providers. Configure your credentials, add the provider to createAuth(), and the framework handles the full flow: authorization redirect, callback handling, PKCE, token exchange, and account linking.

Setup

import { createAuth, google, github, discord } from 'vertz/server';

const auth = createAuth({
  session: { strategy: 'jwt', ttl: '60s' },
  providers: [
    google({
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
      redirectUrl: 'http://localhost:3000/api/auth/oauth/google/callback',
    }),
    github({
      clientId: env.GITHUB_CLIENT_ID,
      clientSecret: env.GITHUB_CLIENT_SECRET,
      redirectUrl: 'http://localhost:3000/api/auth/oauth/github/callback',
    }),
    discord({
      clientId: env.DISCORD_CLIENT_ID,
      clientSecret: env.DISCORD_CLIENT_SECRET,
      redirectUrl: 'http://localhost:3000/api/auth/oauth/discord/callback',
    }),
  ],
  oauthEncryptionKey: env.OAUTH_ENCRYPTION_KEY,
  oauthAccountStore: myOAuthAccountStore,
});
This generates two routes per provider:
GET /api/auth/oauth/google          → Redirect to Google consent screen
GET /api/auth/oauth/google/callback → Handle Google callback

GET /api/auth/oauth/github          → Redirect to GitHub
GET /api/auth/oauth/github/callback → Handle GitHub callback

GET /api/auth/oauth/discord         → Redirect to Discord
GET /api/auth/oauth/discord/callback → Handle Discord callback

Provider configuration

All providers share the same config interface:
interface OAuthProviderConfig {
  clientId: string;
  clientSecret: string;
  redirectUrl?: string; // Callback URL registered with the provider
  scopes?: string[]; // Override default scopes
}

Default scopes

ProviderDefault scopesEmail trust
Googleopenid, email, profileTrusted (verified by OIDC)
GitHubread:user, user:emailUntrusted
Discordidentify, emailUntrusted

Account linking

When a user signs in via OAuth, the system follows a three-path resolution:
  1. Existing link found — The provider account is already linked to a user. Sign them in.
  2. Trusted email match — The provider verifies the email (Google OIDC). If a user with that email exists, auto-link the provider account and sign in.
  3. No match — Create a new user account and link the provider account.
GitHub and Discord don’t verify email ownership at the OAuth level, so they never auto-link to existing accounts. Each GitHub/Discord sign-in creates a new account unless a link already exists in the OAuthAccountStore.

Required configuration

Encryption key

OAuth state is stored in an encrypted cookie (AES-256-GCM). You must provide an encryption key:
oauthEncryptionKey: env.OAUTH_ENCRYPTION_KEY, // any string — HKDF derives the actual key
Generate one:
openssl rand -base64 32

OAuthAccountStore

The account store tracks which provider accounts are linked to which users:
interface OAuthAccountStore {
  linkAccount(userId: string, provider: string, providerId: string, email?: string): Promise<void>;
  findByProviderAccount(provider: string, providerId: string): Promise<string | null>;
  findByUserId(userId: string): Promise<{ provider: string; providerId: string }[]>;
  unlinkAccount(userId: string, provider: string): Promise<void>;
  dispose(): void;
}
For development, use the built-in in-memory store:
import { InMemoryOAuthAccountStore } from 'vertz/server';

const auth = createAuth({
  // ...
  oauthAccountStore: new InMemoryOAuthAccountStore(),
});
For production, implement the interface backed by your database.

Redirect configuration

Control where users land after OAuth:
const auth = createAuth({
  // ...
  oauthSuccessRedirect: '/dashboard', // Default: '/'
  oauthErrorRedirect: '/auth/error', // Default: '/auth/error'
});

Security

ProtectionHow
PKCES256 code challenge for Google and Discord (GitHub doesn’t support PKCE)
State cookieAES-256-GCM encrypted, prevents CSRF on the callback
OIDC nonceValidated in Google ID tokens to prevent replay attacks
Rate limiting10 OAuth initiations per 5 minutes per IP
Email validationEmpty emails rejected before user creation

Environment variables

Use createEnv() to validate OAuth credentials at startup:
import { createEnv } from 'vertz/server';
import { s } from 'vertz/schema';

export const env = createEnv({
  schema: s.object({
    GOOGLE_CLIENT_ID: s.string(),
    GOOGLE_CLIENT_SECRET: s.string(),
    GITHUB_CLIENT_ID: s.string(),
    GITHUB_CLIENT_SECRET: s.string(),
    OAUTH_ENCRYPTION_KEY: s.string(),
  }),
});

OAuth-only users

Users created via OAuth have a null password hash. If they attempt email/password sign-in, the auth system runs a timing-safe dummy bcrypt comparison — same response time, always fails. This prevents user enumeration via the sign-in endpoint.