Guides

Sign in with Google & GitHub

Google and GitHub are built in. This guide covers registering your app, the two routes to write, and the mistakes that produce a redirect back with no session.

Yatta ships an OAuth subsystem with Google and GitHub pre-registered. You add credentials, write two routes, and identity linking, PKCE and state validation are handled for you.

What happens under the hood

  • State is a random token signed with HMAC. The provider returns it unchanged; a mismatch is rejected, which is what blocks CSRF on the callback.
  • PKCE is used for Google. A verifier is generated, hashed into a challenge, and checked when the code is exchanged.
  • Identity linking: if an account with the same email already exists, it is reused instead of creating a duplicate.
  • The session is issued by Yatta — the provider's token is never handed to your client.

Register your app

Two applications, one per provider.

Google — console.cloud.google.comtext
1. Google Cloud Console → Credentials → Create Credentials → OAuth client2. Application type: Web application3. Authorised redirect URIs:     http://localhost:4000/auth/callback/google     https://yourdomain.com/auth/callback/google4. Copy the Client ID and Client Secret
GitHub — github.com/settings/developerstext
1. Settings → Developer settings → OAuth Apps → New OAuth App2. Homepage URL: https://yourdomain.com3. Authorization callback URL:     http://localhost:4000/auth/callback/github     https://yourdomain.com/auth/callback/github4. Generate a client secret

Set the callback URL

The redirect URI must match exactly — scheme, host, path and trailing slash. Use localhost in development and your real domain in production.

Environmentbash
APP_URL=http://localhost:4000        # developmentAPP_URL=https://yourdomain.com       # production GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.comGOOGLE_CLIENT_SECRET=xxxxx GITHUB_CLIENT_ID=Ov23li...GITHUB_CLIENT_SECRET=xxxxx

Google

No code required — the provider is pre-registered and reads its credentials from the environment variables above.

yatta/func/auth.tsts
export const auth = createAuth({  secret: process.env.AUTH_SECRET!,  store: new SQLiteAuthStore(),  email: { mailer, appUrl: process.env.APP_URL! },});   // google is registered automatically

GitHub

Also automatic, same environment variables:

yatta/func/auth.tsts
// github is registered automatically too.// The scaffolded schema already has an `identities` table for this.

The two routes you need

Start sends the browser to the provider; callback receives them back and issues a session.

yatta/backend/auth/[provider]/start.tsts
import { API, createAPI } from "yatta/api";import { auth } from "../../../func/auth"; const api = createAPI(); api.get(async (ctx) => {  const provider = ctx.params.provider;          // "google" | "github"  const redirectUri = `${process.env.APP_URL}/auth/callback/${provider}`;   const { url, state, codeVerifier } =    await auth.oauth.getAuthorizationUrl(provider, redirectUri);   // Persist state (and PKCE verifier) for the callback to check.  await Bun.write(`/tmp/oauth-${state}.json`, JSON.stringify({    state,    codeVerifier: codeVerifier ?? null,  }));   return Response.redirect(url, 302);}); export default api;
yatta/backend/auth/callback/[provider].tsts
import { API, createAPI } from "yatta/api";import { auth } from "../../../func/auth"; const api = createAPI(); api.get(async (ctx) => {  const provider = ctx.params.provider;  const url = new URL(ctx.req.url);   const code = url.searchParams.get("code")!;  const state = url.searchParams.get("state")!;   // Restore what we saved when the flow started.  const saved = await Bun.file(`/tmp/oauth-${state}.json`).json().catch(() => null);  if (!saved) return API.json({ error: "Unknown state" }, { status: 400 });   const result = await auth.oauth.handleCallback({    provider,    code,    expectedState: saved.state,    redirectUri: `${process.env.APP_URL}/auth/callback/${provider}`,    codeVerifier: saved.codeVerifier ?? undefined,    req: ctx.req,  });   // Issues a Yatta session; the provider token stays server-side.  return result.toResponse ?? API.json({ user: result.user });}); export default api;
Warning
The saved state must survive the redirect. A file in /tmp works for a single process but not behind a load balancer. Use the cache store instead — see Cache.

Account linking

By default a matching email links to the existing user rather than creating a second account. Pass linkToUserId to attach an additional provider to a user who is already signed in.

link a second providerts
const result = await auth.oauth.handleCallback({  provider,  code,  expectedState,  redirectUri,  // The currently signed-in user  linkToUserId: currentUserId,});
yatta/backend/auth/link.tsts
import { API, createAPI } from "yatta/api";import { auth } from "../../func/auth"; const api = createAPI(); api.get(async (ctx) => {  const provider = ctx.url.searchParams.get("provider") ?? "google";  const session = await getSession(ctx);   if (!session) return API.json({ error: "Unauthorized" }, { status: 401 });   const redirectUri = `${process.env.APP_URL}/auth/callback/${provider}`;  const { url, state, codeVerifier } =    await auth.oauth.getAuthorizationUrl(provider, redirectUri);   // Carry the user id through the round trip so the callback knows  // which account to attach this provider to.  const signed = `${state}.${await sign(state + session.user.id)}`;  await saveOAuthState(state, { codeVerifier, userId: session.user.id });   return Response.redirect(url.replace(/state=[^&]*/, `state=${signed}`), 302);}); export default api;

Other providers

Anything speaking OAuth 2.0 can be added. Pass the endpoints and a mapper that normalises the profile.

Discordts
auth.oauth.registerProvider({  name: "discord",  clientId: process.env.DISCORD_CLIENT_ID!,  clientSecret: process.env.DISCORD_CLIENT_SECRET!,  authorizeUrl: "https://discord.com/oauth2/authorize",  tokenUrl: "https://discord.com/api/oauth2/token",  userInfoUrl: "https://discord.com/api/users/@me",  scopes: ["identify", "email"],  mapProfile: (data) => ({    id: String(data.id),    email: String(data.email ?? ""),    name: String(data.global_name ?? data.username),  }),});

Set usePkce: true for providers that support it. It is always safe to enable.

Common problems

  • redirect_uri_mismatch — the URI registered with the provider differs by even a trailing slash. Copy it exactly.
  • Invalid or forged OAuth state — the state you stored was not found on callback. This is the anti-CSRF check working; it usually means the saved state expired or was stored per-process while the callback landed on another.
  • Empty session after callback — return result.toResponse; it carries the session cookie.
  • Google consent screen not published — an app in “Testing” mode only allows accounts you list as test users.

See also Authentication for the surrounding APIs, and Protect a route for using the session.