Authentication
Built-in auth with better-auth — signup, login, session management
Gencow includes built-in authentication powered by better-auth. No external services (Clerk, Auth0) needed.
Auth modes
Gencow supports two client-side auth strategies: token (default) and cookie. They differ in how the browser stores credentials and how protected API calls are authorized.
Client and backend both matter. The frontend picks a strategy to match deployment (cross-origin SPA vs same-origin SSR). For email/password, both modes are always available — the client calls different routes (/api/auth/sign-in vs /api/auth/sign-in/email) and the server accepts either. For OAuth, the backend restricts which completion styles are allowed via oauth.allowedReturnModes in gencow/auth.ts — ["token"], ["cookie"], or both. A cookie-only backend rejects token-mode OAuth completion; a token-only backend rejects cookie-mode completion and blocks /api/auth/oauth/exchange.
Once a session exists, protected API calls can be authorized with either style of credential:
| Credential | Typical source | Used by |
|---|---|---|
Authorization: Bearer <JWT> |
Short-lived access token (5 min) minted by Gencow | Token-mode clients |
Authorization: Bearer <sessionToken> |
Better Auth session token | Token-mode refresh / bearer plugin |
Cookie: better-auth.session_token |
HttpOnly session cookie | Cookie-mode clients (and SSR) |
Choose the client strategy to match your deployment:
Token mode (kind: "token") |
Cookie mode (kind: "cookie") |
|
|---|---|---|
| Best for | Separate frontend and API origins (e.g. frontend.com → backend.gencow.app) |
Same origin as the Gencow API (e.g. project.gencow.app) |
| Protected requests | Authorization: Bearer <JWT> |
credentials: "include" (browser sends the HttpOnly cookie) |
| JS-visible token | Yes — short-lived JWT in memory | No — session lives in an HttpOnly cookie |
| Page reload | Restores sessionToken from storage, then refreshes JWT via /api/auth/token |
Browser resends the cookie automatically |
| SSR | Not directly; framework server mode + a frontend BFF bridge can cover cross-host SSR | Supported — forward request cookies to Gencow |
| Default | Yes — createAuthClient() without strategy |
Opt in explicitly |
// Token mode (default) — cross-origin SPA
createAuthClient(baseUrl);
// Cookie mode — same-origin / SSR
createAuthClient(baseUrl, { strategy: { kind: "cookie" } });Token flow
Sign In → Server returns { sessionToken, token (JWT), user }
│
├── JWT (5min) → stored in memory only
├── sessionToken (7d) → stored in localStorage (default)
└── user → stored in localStorage (optimistic UI)
Page reload → sessionToken from localStorage
→ POST /api/auth/token → new JWT
→ UI shows user immediately (from localStorage cache)
JWT expires → protected queries/mutations refresh first using sessionToken (no user action needed)Protected HTTP and realtime calls refresh the short-lived JWT before running. Concurrent wake-ups share one in-flight /api/auth/token request. For XSS-sensitive surfaces, keep sessionToken in memory instead of localStorage — a full page reload then requires signing in again:
createAuthClient(baseUrl, {
strategy: { kind: "token", sessionTokenStorage: "memory" },
});No cookies required for API calls. Token mode avoids cross-origin cookie issues (e.g.
frontend.comcallingbackend.gencow.app).
Cookie flow
Sign In → POST /api/auth/sign-in/email
→ Server sets better-auth.session_token (HttpOnly cookie)
→ Client stores user in memory for optimistic UI
Protected API → fetch(..., { credentials: "include" })
→ Browser attaches cookie; server validates session
Page reload → Cookie sent automatically; GET /api/auth/me hydrates userProtected calls run even when useAuth().token is null — the server returns 401 if the cookie is missing or invalid. When migrating from token mode, clear leftover localStorage entries once. For production, set BETTER_AUTH_URL to your HTTPS app origin so Better Auth issues the secure cookie name.
For OAuth, configure allowed completion modes in gencow/auth.ts. Defaults to token-only (["token"]); opt into cookie mode or both explicitly:
// gencow/auth.ts
export default defineAuth({
oauth: {
// Token-only OAuth (default) — cross-origin SPAs
allowedReturnModes: ["token"],
// Cookie-only OAuth — same-origin / HttpOnly session required
// allowedReturnModes: ["cookie"],
// Both — only when the same backend intentionally serves both client styles
// allowedReturnModes: ["token", "cookie"],
},
});Email/password sign-in does not use this setting. The client sends its strategy as an OAuth return preference; the server resolves it against allowedReturnModes and rejects disallowed modes.
Frontend Setup
1. Create Auth Client
See Auth modes to choose token (default) or cookie.
// src/lib/auth.ts
import { createAuthClient } from "@gencow/client";
const baseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:5456";
// Pass the same backend origin used by queries, mutations, and realtime tickets.
export const auth = createAuthClient(baseUrl);
export const { signIn, signUp, signOut } = auth;2. Runtime client + GencowProvider
// src/lib/apiClient.ts
import { createGencowClient } from "@gencow/client";
import { api } from "../gencow/api";
import { auth } from "./auth";
const baseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:5456";
export const apiClient = createGencowClient({ api, baseUrl, auth });// src/main.tsx
import { GencowProvider } from "@gencow/react";
import { apiClient } from "./lib/apiClient";
function AppWrapper({ children }: { children: React.ReactNode }) {
return (
<GencowProvider apiClient={apiClient}>
{children}
</GencowProvider>
);
}3. Use in Components
import { signIn, signUp, signOut } from "./lib/auth";
import { useAuth } from "@gencow/react";
function AuthComponent() {
const { user, isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <LoginForm />;
}
return (
<div>
<p>Welcome, {user?.name || user?.email}</p>
<button onClick={() => signOut()}>Sign Out</button>
</div>
);
}Sign Up
try {
const user = await signUp("[email protected]", "password123", "John Doe");
// user = { id, email, name }
// Session is automatically established
} catch (e) {
console.error(e.message); // "Email already registered" etc.
}Sign In
try {
const user = await signIn("[email protected]", "password123");
// user = { id, email, name }
// Token mode: JWT + sessionToken stored client-side
// Cookie mode: HttpOnly session cookie set by the server
} catch (e) {
console.error(e.message); // "Invalid credentials" etc.
}Sign Out
await signOut();
// Token mode: clears JWT, sessionToken, and cached user from memory/storage
// Cookie mode: clears the HttpOnly session cookie server-sideGoogle SSO
Add the Google SSO starter:
gencow add ssoThe command enables Google login in gencow/auth.ts, adds GOOGLE_CLIENT_ID and
GOOGLE_CLIENT_SECRET placeholders, and creates React examples under src/auth/.
The implementation uses Better Auth's native socialProviders.google provider.
import { GoogleSignInButton } from "./auth/google-sign-in-button";
export function LoginPage() {
return <GoogleSignInButton />;
}Route /auth/callback to the generated callback example. Google Cloud Console must use the backend callback URL:
Authorized JavaScript origin:
https://<app>.<domain>
Authorized redirect URI:
https://<app>.<domain>/api/auth/callback/googleFor local frontend development, also add the frontend localhost origin and backend callback URI. Keep GOOGLE_CLIENT_SECRET only in backend/app environment variables; never put it in frontend code.
OAuth Callback Handling
OAuth completion is handled by completeSocialSignIn(code?). The same callback page works for both auth strategies:
- Token mode redirects back with
?code=...;completeSocialSignIn(code)exchanges that code for{ user, sessionToken, token }. - Cookie mode redirects back with
?oauth=success;completeSocialSignIn(undefined)reads the current user from the HttpOnly cookie and does not exposesessionTokento JavaScript.
// src/auth/callback.tsx
import { useEffect, useState } from "react";
import { auth } from "../lib/auth";
export function AuthCallbackPage() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get("code") ?? undefined;
const oauthSuccess = params.get("oauth") === "success";
const oauthError = params.get("error");
if (oauthError) {
setError(oauthError);
return;
}
if (!code && !oauthSuccess) {
setError("Missing OAuth callback result");
return;
}
auth.completeSocialSignIn(code)
.then(() => {
window.history.replaceState({}, "", "/");
window.location.assign("/");
})
.catch((e) => {
setError(e instanceof Error ? e.message : String(e));
});
}, []);
if (error) return <p>{error}</p>;
return <p>Completing sign in...</p>;
}Start OAuth from any button with signInSocial():
import { auth } from "../lib/auth";
export function GoogleSignInButton() {
return (
<button
type="button"
onClick={() => auth.signInSocial("google", {
callbackURL: `${window.location.origin}/auth/callback`,
})}
>
Continue with Google
</button>
);
}The SDK automatically sends the selected auth strategy to the server as an OAuth return preference. The server still enforces the allowed modes from trusted backend config.
gencow add sso v1 exposes Google only. Kakao, Naver, Apple, GitHub, and Google API scopes such as Drive or Gmail are separate provider/Connections work.
Public Origins
Gencow uses one public-origin policy for API CORS, Better Auth trusted origins, and OAuth callback validation. The hosted app origin and active custom domains are managed automatically. External frontends are declared in code:
/** @type {import("@gencow/core").GencowConfig} */
export default {
frontendOrigins: ["https://my-frontend.vercel.app"],
};Origins must be exact bare origins. Wildcards, paths, query strings, hashes, and userinfo are rejected; production external origins must use https://. Local http://localhost:<port> and http://127.0.0.1:<port> are accepted for development.
Use gencow origins list or the Dashboard Public Origins panel to inspect the effective policy. Do not add custom domains to CORS manually; once the domain is active for the app, OAuth callback URLs on that domain are allowed by the managed policy.
Cross-Host SSR Bridge
When the frontend and Gencow backend are on different hosts, the SSR server cannot read the browser's token-mode sessionToken from localStorage. Use a frontend-owned bridge route and a vetted session library such as iron-session:
// src/lib/auth-client.ts
import { createAuthClient } from "@gencow/client";
export const auth = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_GENCOW_URL,
authBridge: {
basePath: "/api/auth",
oauthCallbackPath: "/auth/callback",
},
});Mount the bridge handlers in your frontend app. The session adapter should delegate cookie encryption/sealing to iron-session, Auth.js, or another reviewed session library.
// app/api/auth/[...action]/route.ts
import { createGencowAuthBridgeHandlers } from "@gencow/client";
import { bridgeSessionAdapter } from "@/lib/session";
const handlers = createGencowAuthBridgeHandlers({
gencowBaseUrl: process.env.NEXT_PUBLIC_GENCOW_URL!,
session: bridgeSessionAdapter,
});
export async function POST(req: Request) {
return handlers.handle(req);
}Bridge sign-in, sign-up, sign-out, OAuth exchange, and JWT refresh go through /api/auth/*. The browser receives only the short-lived JWT; the Better Auth sessionToken stays inside the frontend HttpOnly cookie. Google OAuth still starts on the Gencow backend, but signInSocial() defaults the callback to the bridge frontend route when authBridge.oauthCallbackPath is set.
SSR and Server Actions
Use the bridge cookie from server code, then call Gencow with Bearer sessionToken:
import { createServerGencowClient } from "@gencow/client";
import { getGencowServerAuth, useServerQuery } from "@gencow/client/next";
import { api } from "@/lib/api";
import { getBridgeSession } from "@/lib/session";
const auth = await getGencowServerAuth(getBridgeSession);
if (!auth) redirect("/login");
const profile = await useServerQuery(api.users.profile, undefined, {
baseUrl: process.env.NEXT_PUBLIC_GENCOW_URL!,
auth,
});
const serverClient = createServerGencowClient({
api,
baseUrl: process.env.NEXT_PUBLIC_GENCOW_URL!,
auth,
});
await serverClient.call.mutate(api.users.updateDisplayName, { displayName });For ISR pages, prefetch only public Gencow queries on the server and load personalized UI from client components after hydration. See apps/examples/nextjs-ssr for React SSR, React ISR, TanStack SSR, TanStack ISR, OAuth, and a Server Action mutation.
useAuth Hook
import { useAuth } from "@gencow/react";
const { token, user, isAuthenticated } = useAuth();| Property | Type | Description |
|---|---|---|
token |
string | null |
Short-lived JWT access token (token mode; auto-refreshed). null in cookie mode. |
user |
{ id, email, name } | null |
Current user info |
isAuthenticated |
boolean |
Whether user is logged in |
Backend Auth
Auth Configuration
Customize auth in gencow/auth.ts:
import { defineAuth } from "@gencow/core";
export default defineAuth({
user: {
additionalFields: {
role: { type: "text", default: "user" },
},
},
// Add better-auth plugins/options:
// betterAuth: (defaults) => ({
// ...defaults,
// plugins: [
// ...((defaults.plugins as unknown[]) ?? []),
// // better-auth plugin instances
// ],
// }),
// Add email verification:
// emailVerification: {
// sendVerificationEmail: async ({ email, url }) => {
// // Send email using your preferred provider
// },
// },
});Custom User Fields
You can extend the auth user table in two ways:
1. additionalFields in gencow/auth.ts — for fields Better Auth should know about (session, JWT, signup defaults). Codegen reflects them in gencow/generated/schema-auth.gen.ts:
// gencow/auth.ts
export default defineAuth({
user: {
additionalFields: {
role: { type: "text", default: "user" },
},
},
});Supported field types: text, boolean, integer, and timestamp.
2. Own gencow/schema-auth.ts directly — for RLS, extra columns on auth tables, or full control. Copy the contents of gencow/generated/schema-auth.gen.ts into schema-auth.ts, edit in place, and remove the re-export. Codegen never overwrites your file; merge upstream changes from .gen.ts when plugins or additionalFields change.
Default scaffold (re-export only):
// gencow/schema-auth.ts
export * from "./generated/schema-auth.gen";After codegen, generate and apply migrations:
gencow codegen
gencow db:generate
gencow db:push --localgencow/generated/schema-auth.gen.ts is regenerated on every gencow codegen (when codegen.authSchema is enabled). gencow/schema-auth.ts is yours — re-export the gen file or fork it, but not both.
To disable auth schema codegen entirely:
// gencow.config.js
/** @type {import('@gencow/core').GencowConfig} */
export default {
codegen: {
authSchema: false,
},
};For security, custom auth fields are not accepted from signup input by default. That means a field like role gets its default value, and your app should update it from trusted server/admin code.
Auth API Endpoints (Reference)
These are handled automatically by createAuthClient() — you don't need to call them directly:
| Endpoint | Method | Description |
|---|---|---|
/api/auth/sign-up |
POST | Sign up → { user, sessionToken, token } |
/api/auth/sign-in |
POST | Sign in → { user, sessionToken, token } |
/api/auth/token |
POST | Refresh JWT (Bearer sessionToken) |
/api/auth/sign-out |
POST | Sign out (Bearer sessionToken) |
/api/auth/sign-up/email |
POST | Cookie-mode native sign up |
/api/auth/sign-in/email |
POST | Cookie-mode native sign in |
/api/auth/sign-in/social/redirect |
GET | Start OAuth/social sign in |
/api/auth/oauth/exchange |
POST | Token-mode OAuth code exchange |
/api/auth/me |
GET | Cookie-mode safe user lookup → { user } |
❌ Common Mistakes
// ❌ Don't create custom auth routes
fetch("/auth/register", { ... })
fetch("/api/users/login", { ... })
// ❌ Don't manage JWT tokens manually
localStorage.setItem("token", jwt)
headers: { Authorization: `Bearer ${myToken}` }
// ❌ Don't hand-roll cookie mode fetches
fetch("/api/auth/sign-in/email", { credentials: "include" })
// ✅ Use createAuthClient() from @gencow/client — it handles everything
import { createAuthClient } from "@gencow/client";
export const auth = createAuthClient(baseUrl, { strategy: { kind: "cookie" } });
export const { signIn, signUp, signOut } = auth;
await signIn("[email protected]", "password123");
// useAuth() inside <GencowProvider apiClient={apiClient}>Next Steps
- Realtime — WebSocket sync
- Storage — File uploads
- Deployment — Environment variables for auth