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.
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 Secret1. 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 secretSet 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.
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=xxxxxNo code required — the provider is pre-registered and reads its credentials from the environment variables above.
export const auth = createAuth({ secret: process.env.AUTH_SECRET!, store: new SQLiteAuthStore(), email: { mailer, appUrl: process.env.APP_URL! },}); // google is registered automaticallyGitHub
Also automatic, same environment variables:
// 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.
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;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;/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.
const result = await auth.oauth.handleCallback({ provider, code, expectedState, redirectUri, // The currently signed-in user linkToUserId: currentUserId,});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.
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.