Skip to main content

Social login (Passport.js)

Covara can drive any Passport.js OAuth2 strategy as a social login — GitHub, Discord, Google, Facebook, Spotify, Twitch, GitLab, Slack, and the hundreds of others in the Passport catalog. You construct the strategy exactly as its docs show, wrap it with fromPassport, and hand it to useAuth. A successful login mints the same session cookie as a password login, so the rest of your app — getUser(c), scopes, the client — works unchanged.

It runs on Node and Cloudflare Workers. See How it works on Workers.

Quick start

npm install passport-github2 # or any passport-* OAuth2 strategy
import { Strategy as GitHubStrategy } from "passport-github2";
import { useAuth, fromPassport, cookieSession } from "covara";

const { router, middleware } = useAuth({
// Social login works with ANY session strategy — swap for
// jwtSession({ secret, getUserById }) to have GitHub login issue JWTs.
session: cookieSession({
getUserById: async (id) => db.query.users.findFirst({ where: eq(users.id, id) }),
}),
social: {
providers: [
fromPassport(
new GitHubStrategy(
{
clientID: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
// Point this at the mounted callback route (see below).
callbackURL: "https://myapp.com/api/auth/social/github/callback",
},
// The value you pass to done() becomes the SocialAccount; the bridge
// normalizes it. Pass the profile straight through.
(_accessToken, _refreshToken, profile, done) => done(null, profile)
)
),
],
findOrCreateUser: async ({ provider, providerAccountId, profile }) => {
// Look up or create your app user from the provider account.
const existing = await db.query.accounts.findFirst({
where: and(eq(accounts.provider, provider), eq(accounts.providerAccountId, providerAccountId)),
});
if (existing) return db.query.users.findFirst({ where: eq(users.id, existing.userId) });

const user = await createUser({ email: profile.email, name: profile.name, image: profile.image });
await linkAccount(user.id, provider, providerAccountId);
return user;
},
successRedirect: "/",
},
});

app.route("/api/auth", router);
app.use("*", middleware);

That mounts two routes under the auth router:

RoutePurpose
GET /api/auth/social/:providerStart login — redirects the browser to the provider
GET /api/auth/social/:provider/callbackProvider callback — exchanges the code, resolves the user, sets the session cookie

:provider is the strategy's name (github, discord, …). Point each strategy's callbackURL at its /callback route.

From the client

Both client libraries start a login with a single call. After the server completes the OAuth flow it sets the session cookie and redirects back to successRedirect.

TypeScript:

import { createClient } from "covara/client";

const client = createClient({ baseUrl: location.origin, credentials: "include" });

client.loginWithSocial("github"); // navigates the browser to the provider
// or build the URL yourself (e.g. for an <a href> or React Native):
const url = client.socialLoginUrl("github");

React:

import { useAuth } from "covara/client/react";

function SignIn() {
const { signInWith, user, isAuthenticated } = useAuth();
if (isAuthenticated) return <p>Hi {user?.name}</p>;
return (
<>
<button onClick={() => signInWith("github")}>Continue with GitHub</button>
<button onClick={() => signInWith("discord")}>Continue with Discord</button>
</>
);
}

If the client is configured with a custom social mount, set it once:

createClient({ baseUrl, social: { basePath: "/auth/social" } });
// React, without a client: useAuth({ socialBasePath: "/auth/social" })

findOrCreateUser

Called after the provider verifies the user. It receives a SocialAccount and must return your app user ({ id, email?, name?, image? }):

interface SocialAccount {
provider: string; // "github"
providerAccountId: string; // normalized profile.id — the account link key
profile: NormalizedProfile; // { id, email, name, image, username, raw }
raw: unknown; // exactly what your strategy's done() returned
}

profile is normalized from the standard Passport Profile shape (displayName, emails, photos, …). For non-standard providers, pass mapProfile to fromPassport or read account.raw directly. If you need the OAuth accessToken/refreshToken, have your strategy's verify return them (e.g. done(null, { profile, accessToken })) with a matching mapProfile, then read them off account.raw.

CSRF & state

The bridge persists the strategy's OAuth state/PKCE handle between the redirect and the callback using a short-lived, httpOnly cookie (covara_oauth_state, default 10 min) plus a state store. A callback with a missing, expired, or mismatched state is rejected before any code exchange — this is the standard OAuth CSRF protection, enforced by the strategy itself.

  • Single Node process: the default in-memory state store is fine.
  • Multiple instances / Cloudflare Workers: the authorize and callback requests can hit different isolates, so use a shared store. If a global KV is configured (setGlobalKV), the KV-backed store is selected automatically. To set it explicitly:
import { createKvSocialStateStore } from "covara";

social: {
providers: [/* ... */],
findOrCreateUser: async () => { /* ... */ },
stateStore: createKvSocialStateStore(), // uses the global KV
}

Configuration

social: {
providers: SocialProvider[]; // from fromPassport(...)
findOrCreateUser: (account, c) => Promise<AuthUser>;
basePath?: string; // default "/social" (under the auth router)
successRedirect?: string; // default "/"
failureRedirect?: string; // default: 401 JSON problem
stateStore?: SocialStateStore; // default: KV if configured, else in-memory
stateCookieName?: string; // default "covara_oauth_state"
stateTtlMs?: number; // default 600000
}

fromPassport(strategy, options?):

fromPassport(strategy, {
name?: string; // defaults to strategy.name; required if it's the generic "oauth2"
scope?: string | string[]; // override the requested scopes
mapProfile?: (raw) => NormalizedProfile,
});

How it works on Workers

Passport strategies don't actually need Express. Strategy.authenticate(req) only reads req.query / req.headers / req.session and signals its result through this.success / fail / redirect / error — methods Passport core injects, not Express. Covara injects them and synthesizes req from the Web request.

The one runtime-specific piece is that OAuth2 strategies do their HTTP through the legacy node-oauth package (node:https). All of its requests funnel through a single method, which the bridge swaps for fetch. After that, token exchange and profile fetch run over fetch — so the whole passport-oauth2 family works on Workers with no node:http.

Scope

The bridge covers OAuth 2.0 strategies (the bulk of the catalog). OAuth 1.0a strategies (e.g. the legacy passport-twitter) sign requests with node-oauth's OAuth1 client and are not supported on Workers. Strategies that pull in heavy Node-only crypto (some SAML/enterprise strategies) are Node-only.

Social login vs the OIDC provider

These solve different problems — see also Federated login:

Social login (social + Passport)Federated login (OIDC provider)
Use caseYour app lets users sign in with GitHub/Discord/Google/…You run an OIDC provider that delegates to upstream IdPs
Provider requirementAny OAuth 2.0 provider (Passport catalog)OIDC-compliant IdP (discovery + id_token)
Covers GitHub/Discord/Spotify/…❌ (no OIDC discovery document)
ResultA Covara session for your appYour provider issues its own OIDC tokens

Reach for social login when you just want "sign in with GitHub." Reach for the OIDC provider when you're building an identity provider for other apps — and note you can use the same Passport strategies there too, via backends.passport, which lets your OIDC provider offer GitHub/Discord/… as upstreams and issue its own tokens.