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 localStorage

Google SSO

Add the Google SSO starter:

gencow add sso

The 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/google

For 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 expose sessionToken to 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).

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 --local

gencow/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