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.
Frontend Setup
1. Create Auth Client
// src/lib/auth.ts
import { createAuthClient } from "@gencow/react";
export const auth = createAuthClient();
export const { signIn, signUp, signOut, useAuth } = auth;
// Optional: pass explicit URL
// createAuthClient(import.meta.env.VITE_API_URL)2. Wrap with GencowProvider
// src/main.tsx
import { GencowProvider } from "@gencow/react";
import { auth } from "./lib/auth";
function AppWrapper({ children }: { children: React.ReactNode }) {
// VITE_API_URL is auto-set by `gencow init` and `gencow dev`
const baseUrl = import.meta.env.VITE_API_URL;
return (
<GencowProvider baseUrl={baseUrl} auth={auth}>
{children}
</GencowProvider>
);
}3. Use in Components
import { signIn, signUp, signOut, useAuth } from "./lib/auth";
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 }
// JWT + sessionToken automatically stored
} catch (e) {
console.error(e.message); // "Invalid credentials" etc.
}Sign Out
await signOut();
// JWT + sessionToken cleared from memory and localStorageGoogle 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.
useAuth Hook
const { token, user, isAuthenticated } = useAuth();| Property | Type | Description |
|---|---|---|
token |
string | null |
Current JWT access token (auto-refreshed) |
user |
{ id, email, name } | null |
Current user info |
isAuthenticated |
boolean |
Whether user is logged in |
How Auth Works
Gencow uses a token-based auth pattern (similar to Firebase/Supabase):
Sign In → Server returns { sessionToken, token (JWT), user }
│
├── JWT (5min) → stored in memory only
├── sessionToken (7d) → stored in localStorage
└── 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 → auto-refresh using sessionToken (no user action needed)For XSS-sensitive surfaces, initialize the client with createAuthClient(baseUrl, { strategy: { kind: "token", sessionTokenStorage: "memory" } }). This keeps the refresh-style sessionToken out of localStorage; the tradeoff is that a full page reload requires signing in again.
No cookies, no rewrites needed. This eliminates cross-origin cookie issues between frontend (port 3000) and backend (port 5456).
Same-Origin Cookie Mode
Token mode is the default and remains the best fit for separate frontend/backend origins. If your frontend and Gencow backend are served from the same origin and you need cookie-visible SSR, opt in explicitly:
// src/lib/auth.ts
import { createAuthClient } from "@gencow/react";
export const auth = createAuthClient(import.meta.env.VITE_API_URL, {
strategy: { kind: "cookie" },
});
export const { signIn, signUp, signOut, useAuth } = auth;Cookie mode calls Better Auth's native email routes and keeps the session in the better-auth.session_token HttpOnly cookie. Gencow queries and mutations use credentials: "include" instead of a JavaScript-visible Authorization token.
When migrating an existing app to cookie mode, clear any old token-mode localStorage entries once:
if (typeof window !== "undefined") {
localStorage.removeItem("gencow_session_token");
localStorage.removeItem("gencow_user");
}For production cookie mode, set BETTER_AUTH_URL to your HTTPS app origin so Better Auth can issue the secure cookie name correctly.
Cookie-mode OAuth should also be enabled on the backend:
// gencow/auth.ts
import { defineAuth } from "@gencow/core";
export default defineAuth({
oauth: {
callbackURL: "https://your-frontend.com/auth/callback",
allowedReturnModes: ["cookie"],
},
});Use allowedReturnModes: ["token"] for token-only apps, or ["token", "cookie"] only when the same backend intentionally supports both client styles. A cookie-only app should not allow token completion, because token completion returns a JavaScript-visible sessionToken.
Backend Auth
Requiring Authentication
import { procedure } from "./runtime";
export const list = procedure.query
.name("tasks.list")
.handler(async ({ context: ctx }) => {
const session = ctx.auth.requireAuth();
return ctx.db.select().from(tasks)
.where(eq(tasks.userId, session.user.id));
});Optional Authentication
export const listPublic = procedure.query
.name("posts.listPublic")
.allowAnonymous()
.handler(async ({ context: ctx }) => {
return ctx.db.select().from(posts)
.where(eq(posts.published, true));
});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
Add scalar fields such as role in gencow/auth.ts, then run codegen and generate migrations:
gencow codegen
gencow db:generate
gencow db:push --localgencow/generated/auth-schema.ts is generated from Better Auth schema metadata during codegen. Custom additionalFields and Better Auth plugins are reflected in the generated schema. Do not edit it manually unless you opt out of auth schema generation.
Supported MVP field types: text, boolean, integer, and timestamp.
If you want full manual control over gencow/generated/auth-schema.ts, you can disable generation in gencow.config.js (the codegen.authSchema flag is experimental):
// gencow.config.js
/** @type {import('@gencow/core').GencowConfig} */
export default {
codegen: {
// experimental: opt out of gencow/generated/auth-schema.ts
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() — it handles everything
const { signIn, signUp, signOut, useAuth } = createAuthClient(baseUrl, {
strategy: { kind: "cookie" },
});
await signIn("[email protected]", "password123");Next Steps
- Realtime — WebSocket sync
- Storage — File uploads
- Deployment — Environment variables for auth