Skip to main content

Overview

The Better Skills API uses Better Auth for authentication, providing:
  • Session-based authentication for web clients
  • Device-code flow for CLI authentication
  • Bearer token support via the bearer plugin
  • Social OAuth (Google, GitHub)

Authentication Flow

Session Context

Every tRPC request creates a context that includes the current session:
import type { Context as HonoContext } from "hono";
import { auth } from "@better-skills/auth";

export type CreateContextOptions = {
  context: HonoContext;
};

type Session = typeof auth.$Infer.Session;

export async function createContext({ context }: CreateContextOptions) {
  const requestHeaders = context.req.raw.headers;
  const session = (await auth.api.getSession({
    headers: requestHeaders,
  })) as Session;

  return {
    session,
    requestHeaders,
  };
}

export type Context = Awaited<ReturnType<typeof createContext>>;

Protected vs Public Procedures

Public procedures allow unauthenticated access:
export const publicProcedure = t.procedure;
Protected procedures enforce authentication:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "Authentication required",
      cause: "No session",
    });
  }
  return next({
    ctx: {
      ...ctx,
      session: ctx.session,
    },
  });
});

Better Auth Configuration

Better Auth is configured in packages/auth/src/index.ts:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { bearer } from "better-auth/plugins/bearer";
import { deviceAuthorization } from "better-auth/plugins/device-authorization";

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
    schema: schema,
  }),
  trustedOrigins,
  socialProviders: {
    google: {
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    },
    github: {
      clientId: env.GITHUB_CLIENT_ID,
      clientSecret: env.GITHUB_CLIENT_SECRET,
    },
  },
  account: {
    accountLinking: {
      enabled: true,
      trustedProviders: ["google", "github"],
    },
  },
  user: {
    additionalFields: {
      onboardingCompleted: {
        type: "boolean",
        input: false,
      },
    },
    deleteUser: {
      enabled: true,
    },
  },
  advanced: {
    crossSubDomainCookies: {
      enabled: true,
      domain: crossSubDomainCookieDomain,
    },
    defaultCookieAttributes: {
      sameSite: "none",
      secure: true,
      httpOnly: true,
    },
  },
  plugins: [
    bearer(),
    deviceAuthorization({
      verificationUri: `${env.CORS_ORIGIN}/device`,
      validateClient: async (clientId) => clientId === "better-skills-cli",
    }),
  ],
});

Web Client Authentication

Login Flow

The web app uses session cookies for authentication:
import { createAuthClient } from "better-auth/react";

const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_SERVER_URL,
});

// Sign in with Google
await authClient.signIn.social({
  provider: "google",
  callbackURL: "/dashboard",
});

// Sign in with GitHub
await authClient.signIn.social({
  provider: "github",
  callbackURL: "/dashboard",
});

Session Management

Better Auth automatically manages session cookies:
// Get current session
const session = await authClient.getSession();

if (session.data) {
  console.log("User:", session.data.user);
} else {
  console.log("Not authenticated");
}

// Sign out
await authClient.signOut();

tRPC Client Setup

Include credentials in tRPC client for session cookies:
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "@better-skills/api";

const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: `${process.env.NEXT_PUBLIC_SERVER_URL}/trpc`,
      fetch: (url, options) => {
        return fetch(url, {
          ...options,
          credentials: "include", // Include session cookies
        });
      },
    }),
  ],
});

CLI Authentication (Device-Code Flow)

The CLI uses the device authorization plugin for authentication without a browser redirect.

Flow Overview

  1. CLI requests a device code
  2. User visits verification URL and enters the code
  3. CLI polls for authorization
  4. Upon success, CLI receives a bearer token

CLI Implementation

The CLI authenticates using Better Auth’s device authorization:
// Request device code
const deviceResponse = await fetch(`${serverUrl}/api/auth/device/request`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ clientId: "better-skills-cli" }),
});

const { device_code, user_code, verification_uri } = await deviceResponse.json();

// Show verification instructions to user
console.log(`Visit ${verification_uri} and enter code: ${user_code}`);

// Poll for token
let token;
while (!token) {
  await sleep(5000);
  const tokenResponse = await fetch(`${serverUrl}/api/auth/device/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      device_code,
      clientId: "better-skills-cli",
    }),
  });

  if (tokenResponse.ok) {
    const data = await tokenResponse.json();
    token = data.access_token;
  }
}

// Store token for future requests

CLI tRPC Client

Use the bearer token in the Authorization header:
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "@better-skills/api";

const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: `${serverUrl}/trpc`,
      headers: async () => {
        const token = await getStoredToken();
        return {
          Authorization: `Bearer ${token}`,
        };
      },
    }),
  ],
});

Admin API Authentication

For server-to-server communication, use the admin API token:

Configuration

Set the BETTER_SKILLS_ADMIN_TOKEN environment variable:
BETTER_SKILLS_ADMIN_TOKEN=your-secure-token

Usage

Include the token in the x-better-skills-admin-token header:
const response = await fetch(`${serverUrl}/trpc/vaults.createEnterprise`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-better-skills-admin-token": process.env.BETTER_SKILLS_ADMIN_TOKEN,
  },
  body: JSON.stringify({
    name: "Acme Corp",
    defaultAdminEmail: "[email protected]",
  }),
});

Admin Procedures

Admin procedures are protected by token validation:
export const adminApiProcedure = t.procedure.use(({ ctx, next }) => {
  const configuredToken = process.env.BETTER_SKILLS_ADMIN_TOKEN;
  if (!configuredToken || configuredToken.trim().length === 0) {
    throw new TRPCError({
      code: "INTERNAL_SERVER_ERROR",
      message: "Admin API token is not configured",
    });
  }

  const providedToken = ctx.requestHeaders.get("x-better-skills-admin-token");
  if (!providedToken || providedToken !== configuredToken) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "Invalid admin API token",
    });
  }

  return next({
    ctx: {
      ...ctx,
      adminApiToken: providedToken,
    },
  });
});
Example admin procedure:
createEnterprise: adminApiProcedure
  .input(
    z.object({
      name: z.string().min(1),
      slug: z.string().min(1).max(64).optional(),
      color: z.string().nullable().optional(),
      defaultAdminEmail: z.string().email(),
    }),
  )
  .output(enterpriseVaultOutput)
  .mutation(async ({ input }) => {
    // Create enterprise vault
  })

Session User Access

In protected procedures, access the authenticated user via ctx.session:
me: protectedProcedure.query(({ ctx }) => {
  return {
    user: {
      id: ctx.session.user.id,
      name: ctx.session.user.name,
      email: ctx.session.user.email,
    },
  };
})

Auth Endpoints

Better Auth endpoints are mounted at /api/auth/*:
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
Available endpoints:
  • /api/auth/sign-in/social - Social OAuth sign-in
  • /api/auth/sign-out - Sign out
  • /api/auth/session - Get current session
  • /api/auth/device/request - Request device code (CLI)
  • /api/auth/device/token - Poll for token (CLI)

Security Considerations

  • Cookies use httpOnly, secure, and sameSite: "none" attributes
  • Cross-subdomain support enabled for multi-domain deployments
  • Trusted origins configured via CORS_ORIGIN and BETTER_AUTH_URL
  • Account linking allowed for Google and GitHub providers
  • Device authorization validates clientId === "better-skills-cli"

Troubleshooting

Session Not Found

Ensure cookies are included in requests:
fetch(url, {
  credentials: "include", // Required for session cookies
});

CLI Token Expired

Re-authenticate using the device-code flow and update the stored token.

CORS Errors

Verify the request origin matches the configured CORS_ORIGIN or its www/non-www variant.

Build docs developers (and LLMs) love