PlaycademyPlaycademy

Authentication

Add user authentication with platform sessions and OAuth providers

Overview

The Playcademy platform already provides user identity through the SDK.

This authentication integration is optional. Add it based on your needs.

FeatureDo I need this?
Basic user data (ID, username, etc.)No (Platform SDK provides this)
Playcadem data (inventory, credits, etc.)No (Platform SDK provides this)
Custom user fields (avatar, bio, etc.)Yes (Requires authentication integration)
OAuth providers (GitHub, Google)Yes (Requires authentication integration)
Standalone mode (outside Playcademy)Yes (Requires authentication integration)
Account settings & user managementYes (Requires authentication integration)

Need help? Ask us on Discord

Getting Started

$ playcademy init  # Select "Yes" for Database, then "Yes" for Authentication
$ playcademy auth init

This scaffolds a complete authentication setup:

auth.ts
[...all].ts
protected.ts
auth.ts
.env.example

Database Required

Authentication requires a database. If you don't have one yet, you'll be prompted to add it.

Configure Your Secret

After scaffolding, add the required secret to your .env file:

.env
BETTER_AUTH_SECRET=your-secret-here

Required Secret

BETTER_AUTH_SECRET must be at least 32 characters and generated with high entropy.

The dev server will fail to start without it.


How It Works

The authentication integration provides dual-mode authentication that works both on Playcademy and as a standalone project.

Platform Mode

When launched from Playcademy, users are automatically signed in.

  • Platform provides a JWT token that's exchanged for a Better Auth session
  • No login UI required; authentication is seamless

Standalone Mode

When accessed directly, users authenticate via your configured providers.

  • Email/password, GitHub OAuth, or Google OAuth
  • You build the login UI using your own components

Authentication Configuration

The CLI creates server/lib/auth.ts with your Better Auth configuration:

server/lib/auth.ts
export function getAuth(c: Context) {
    const db = getDb(c.env.DB)

    return betterAuth({
        database: drizzleAdapter(db, { provider: 'sqlite' }),

        trustedOrigins: ['http://localhost:5173'],

        emailAndPassword: { enabled: true },

        plugins: [playcademy()],

        advanced: {
            defaultCookieAttributes: {
                sameSite: 'none',
                secure: true,
                path: '/',
            },
        },
    })
}

This configuration is fully customizable; you can add/remove providers and adjust settings as you see fit.


Using Authentication in Routes

Access the authenticated user via c.get('user'):

server/api/save-progress.ts
import type { User } from '../lib/auth'

export async function POST(c: Context) {
    const user = c.get('user') as User | undefined

    if (!user) {
        return c.json({ error: 'Authentication required' }, 401)
    }

    // user.id is the Better Auth user ID
    // user.playcademyUserId links to platform identity (if launched from Playcademy)
    const { level, score } = await c.req.json()

    // Save progress for this user...
    return c.json({ success: true })
}

The CLI creates a sample protected route at server/api/sample/protected.ts demonstrating this pattern.


Frontend Usage

Integrating authentication into your frontend requires:

  1. Configuring an auth client using the Better Auth SDK
  2. Adding the Playcademy plugin to the auth client
src/lib/auth.ts
import { createAuthClient } from 'better-auth/react'

import { playcademy } from '@playcademy/better-auth/client'

export const authClient = createAuthClient({
    plugins: [playcademy()],
})

Use it in your components:

src/App.tsx
import { authClient } from './lib/auth'

export function App() {
    const { data: session, isPending } = authClient.useSession()

    if (isPending) {
        return <div>Loading...</div>
    }

    if (!session) {
        // Show login UI
        return <div>Please sign in</div>
    }

    return <div>Welcome, {session.user.name}!</div>
}

Adding Authentication Strategies

Email/Password

$ playcademy init  # Select Email/password in auth strategies
$ playcademy auth add email

OAuth Providers

Add GitHub or Google OAuth:

$ playcademy init  # Select GitHub OAuth or Google OAuth
$ playcademy auth add github$ playcademy auth add google

Create OAuth App

Create an OAuth app in the GitHub/Google developer console.

Add Local Credentials

Add credentials to .env for local development:

.env
BETTER_AUTH_SECRET=your-secret-here
GITHUB_CLIENT_ID=your_client_id
GITHUB_CLIENT_SECRET=your_client_secret

Push Secrets

Push secrets to your deployed project:

$ playcademy secrets push

See Secrets Management for more details.

Configure Callback URL

Set the callback URL in your OAuth app:

https://your-project.playcademy.gg/api/auth/callback/github

Deploy

$ playcademy deploy

Database Schema

The auth schema includes four tables:

server/db/schema/auth.ts
/**
 * Better Auth Schema
 *
 * Database tables for Better Auth authentication system
 */
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'

export const user = sqliteTable('user', {
    id: text('id').primaryKey(),
    name: text('name').notNull(),
    email: text('email').notNull().unique(),
    emailVerified: integer('emailVerified').notNull(),
    image: text('image'),
    createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
    updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),

    // Platform linkage: Links this user to a Playcademy platform identity
    // When users launch from Playcademy, their platform identity
    // is verified and linked to a Better Auth session via this field
    playcademyUserId: text('playcademy_user_id').unique(),
})

export const session = sqliteTable('session', {
    id: text('id').primaryKey(),
    expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(),
    token: text('token').notNull().unique(),
    createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
    updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),
    ipAddress: text('ipAddress'),
    userAgent: text('userAgent'),
    userId: text('userId')
        .notNull()
        .references(() => user.id, { onDelete: 'cascade' }),
})

export const account = sqliteTable('account', {
    id: text('id').primaryKey(),
    accountId: text('accountId').notNull(),
    providerId: text('providerId').notNull(),
    userId: text('userId')
        .notNull()
        .references(() => user.id, { onDelete: 'cascade' }),
    accessToken: text('accessToken'),
    refreshToken: text('refreshToken'),
    idToken: text('idToken'),
    accessTokenExpiresAt: integer('accessTokenExpiresAt', { mode: 'timestamp' }),
    refreshTokenExpiresAt: integer('refreshTokenExpiresAt', { mode: 'timestamp' }),
    scope: text('scope'),
    password: text('password'),
    createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
    updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),
})

export const verification = sqliteTable('verification', {
    id: text('id').primaryKey(),
    identifier: text('identifier').notNull(),
    value: text('value').notNull(),
    expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(),
    createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
    updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),
})

Adding Custom User Fields

Extend the user table with project-specific fields:

server/db/schema/auth.ts
export const user = sqliteTable('user', {
    // ... standard Better Auth fields ...

    // Add custom fields
    level: integer('level').default(1),
    xp: integer('xp').default(0),
    avatar: text('avatar'),
})

Then push your schema:

Command
$ bun db:push

What's Next?

On this page