Superfunction

Tutorial: Full-Stack Auth

What you'll build

A Next.js app with a real SPFN backend and working authentication:

  • an admin account seeded from env at boot,
  • a login page — email/password for the admin, Google social login for everyone else,
  • a signed-in dashboard guarded by RequireAuth,
  • an admin-only area guarded by RequireRole,

with every screen guard living in a Next.js layout file, so nested pages inherit protection automatically. The finished code is examples/03-auth — every snippet below is taken from it.

1. Create the project

npx spfn@beta create my-app
cd my-app
docker compose up -d       # Postgres + Redis

spfn create scaffolds a Next.js app with the SPFN backend beside it (src/server/), env files included. Check the app runs before adding auth:

pnpm spfn:dev              # Next.js + SPFN API

2. Add @spfn/auth — the four wiring points

pnpm add @spfn/auth

Auth wires into an SPFN app at exactly four places. Miss one and you get 401s or missing tables — check all four.

① Server lifecycle — validates auth env before the DB connects, then seeds admin accounts and initializes RBAC once it's ready (src/server/server.config.ts):

import { defineServerConfig } from '@spfn/core/server';
import { createAuthLifecycle } from '@spfn/auth/server';
import { appRouter } from '@/server/router';

export default defineServerConfig()
    .port(8890)
    .routes(appRouter)
    .lifecycle(createAuthLifecycle())
    .build();

② Router — mount the auth routes as a package and apply authentication globally (src/server/router.ts):

import { defineRouter } from '@spfn/core/route';
import { authRouter, authenticate } from '@spfn/auth/server';
import { getMe } from './routes/me';
import { listExamples, createExample } from './routes/examples';

export const appRouter = defineRouter({ getMe, listExamples, createExample })
    .packages([authRouter])   // mounts /_auth/* and exposes routes on authApi
    .use([authenticate]);     // global auth; public routes opt out per-route

export type AppRouter = typeof appRouter;

Two things people miss here: auth routers register through .packages([authRouter]) — not as a plain route entry — and .use([authenticate]) protects everything, so public routes must opt out explicitly:

export const listExamples = route.get('/examples')
    .skip(['auth'])           // public read — no session required
    .handler(async (c) => { /* … */ });

③ RPC proxy interceptor — one side-effect import in the proxy route (src/app/api/rpc/[routeName]/route.ts). Without it every protected call 401s, because nothing signs the outbound requests:

import '@spfn/auth/nextjs/api';   // self-registers the auth interceptor — MUST come first
import { createRpcProxy } from '@spfn/core/nextjs/server';
import { authRouteMap } from '@spfn/auth';
import { routeMap } from '@/generated/route-map';

export const { GET, POST } = createRpcProxy({
    routeMap: { ...routeMap, ...authRouteMap },
});

④ Migrations — auth ships its tables (users, keys, roles, …) as package migrations:

pnpm spfn db migrate

3. Environment variables — which file gets what

SPFN splits env by who may read it. This is the most common setup stumble:

File Read by Holds
.env.local Next.js and SPFN DATABASE_URL, SPFN_API_URL, SPFN_AUTH_SESSION_SECRET (encrypts the session cookie)
.env.server SPFN server only backend secrets: token/encryption keys, admin seeding, OAuth credentials

Minimum for this tutorial (generate real secrets with openssl rand -base64 32):

# .env.local
DATABASE_URL=postgresql://spfn:spfn@localhost:55432/spfn_dev
SPFN_API_URL=http://localhost:8890
SPFN_AUTH_SESSION_SECRET=<32+ char random secret>

# .env.server
DATABASE_URL=postgresql://spfn:spfn@localhost:55432/spfn_dev
SPFN_AUTH_VERIFICATION_TOKEN_SECRET=<32+ char random secret>
SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v1:<openssl rand -base64 32>

Seed the admin account

Add two more lines to .env.server:

SPFN_AUTH_ADMIN_EMAIL=admin@example.com
SPFN_AUTH_ADMIN_PASSWORD=SecureAdmin123!

On boot, createAuthLifecycle() creates this account if it doesn't exist — you'll see ✅ Admin account created: admin@example.com (superadmin) in the server log. Rules worth knowing:

  • The password must have 8+ chars with upper, lower, number, and special character.
  • This simple two-var format seeds the superadmin role and sets passwordChangeRequired: true.
  • For multiple accounts, explicit roles, or passwordChangeRequired: false, use the JSON form instead: SPFN_AUTH_ADMIN_ACCOUNTS=[{"email":"…","password":"…","role":"admin","passwordChangeRequired":false}]

4. The login page

Email/password sign-in is one authApi call. The RPC interceptor from step ② does the heavy lifting on success — generates the signing key pair and saves the encrypted session cookie. Navigate with a full page load (not router.push) so Server Components re-read the fresh session (src/app/components/login-form.tsx):

'use client';

import { authApi } from '@spfn/auth';
import { useState } from 'react';

export function LoginForm({ redirectTo = '/dashboard' }: { redirectTo?: string })
{
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [error, setError] = useState<string | null>(null);

    async function submit(event: React.FormEvent)
    {
        event.preventDefault();
        try
        {
            await authApi.login.call({ body: { email, password } });
            window.location.assign(redirectTo);
        }
        catch
        {
            setError('Sign-in failed. Check the email and password.');
        }
    }

    return (
        <form onSubmit={submit}>
            <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
            <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
            {error && <p>{error}</p>}
            <button type="submit">Sign in</button>
        </form>
    );
}

The page itself is a Server Component that bounces already-signed-in users (src/app/login/page.tsx):

import { redirect } from 'next/navigation';
import { getSession } from '@spfn/auth/nextjs/server';
import { LoginForm } from '@/app/components/login-form';

export const dynamic = 'force-dynamic';

export default async function LoginPage()
{
    if (await getSession())
    {
        redirect('/dashboard');
    }

    return <LoginForm />;
}

Email/password registration needs the verification-code flow (/_auth/verify-code) and a mail/SMS provider, so this tutorial registers normal users through social login instead.

5. The signed-in screen — RequireAuth in a layout

Guard the layout, not each page — everything under /dashboard inherits it (src/app/dashboard/layout.tsx):

import { RequireAuth } from '@spfn/auth/nextjs/server';
import type { ReactNode } from 'react';

export default function DashboardLayout({ children }: { children: ReactNode })
{
    return <RequireAuth redirectTo="/login">{children}</RequireAuth>;
}

Inside, protected API calls are safe without any per-page checks (src/app/dashboard/page.tsx):

import { api } from '@/lib/api-client';

export const dynamic = 'force-dynamic';

export default async function DashboardPage()
{
    const me = await api.getMe.call({});   // protected route — session guaranteed by the layout

    return <p>Signed in as {me.email} (role: {me.role})</p>;
}

getMe on the server side reads the authenticated user with getAuth(c):

import { route } from '@spfn/core/route';
import { getAuth } from '@spfn/auth/server';

export const getMe = route.get('/me')
    .handler(async (c) =>
    {
        const { user, userId, role } = getAuth(c);

        return { id: userId, email: user.email, role };
    });

6. The admin area — RequireRole in a layout

Same pattern, role-gated (src/app/admin/layout.tsx):

import { RequireRole } from '@spfn/auth/nextjs/server';
import type { ReactNode } from 'react';

export default function AdminLayout({ children }: { children: ReactNode })
{
    return <RequireRole roles={['admin', 'superadmin']} redirectTo="/dashboard">{children}</RequireRole>;
}

roles is an OR match on exact role namessuperadmin does not imply admin, so list both when either should pass (the env-seeded admin is superadmin). Signed-out visitors go to /login; signed-in users without a matching role go to redirectTo. A fallback prop renders in place instead of redirecting, and RequirePermission does the same for fine-grained permissions.

7. Social login — Google

Create an OAuth client in the Google Cloud console (type: Web application) and register the redirect URI:

http://localhost:3890/_auth/oauth/google/callback

That URI is the default — {app origin}/_auth/oauth/google/callback — and it hits your Next.js origin, not the API server: the scaffolded next.config.ts rewrites /_auth/:path* to the SPFN API. Then two lines in .env.server:

SPFN_AUTH_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
SPFN_AUTH_GOOGLE_CLIENT_SECRET=<client secret>

(GitHub, Kakao, Naver, and Apple are built in the same way — SPFN_AUTH_GITHUB_*, SPFN_AUTH_KAKAO_* etc.; more providers plug in via the OAuth provider registry.)

The login button asks the backend for Google's auth URL and follows it:

'use client';

import { authApi } from '@spfn/auth';

async function startLogin()
{
    const { authUrl } = await authApi.getProviderOAuthUrl.call({
        params: { provider: 'google' },
        body: { returnUrl: '/' },
    });
    window.location.assign(authUrl);
}

After the provider round-trip, the callback page finalizes the encrypted session cookie — @spfn/auth/nextjs/client ships it ready-made (src/app/auth/callback/page.tsx):

export { OAuthCallback as default } from '@spfn/auth/nextjs/client';

Social users are registered automatically on first login with the user role — so they reach /dashboard but bounce off /admin, which is exactly the RBAC split this tutorial is after. authApi.oauthProviders.call({}) reports which providers are configured, letting the UI disable unconfigured buttons (the example's OAuthLoginButtons does exactly this).

8. Run the loop

pnpm spfn:dev        # or spfn:server / spfn:next in two terminals

Then walk the same loop this example was verified with:

  1. Open /dashboard signed out → RequireAuth redirects to /login.
  2. Sign in with admin@example.com / the seeded password → back on /dashboard, showing your email and superadmin role via the protected getMe call.
  3. Open /adminRequireRole lets the seeded admin through.
  4. Sign out (authApi.logout) → /dashboard bounces to /login again.
  5. Sign in with Google → /dashboard works, /admin redirects back — a user-role account in an admin-only area.

Where next