Route Authentication
VK loads auth state for every request, but routes are public by default. The
middleware in src/middleware.ts creates Better Auth through
@vergekit/core/auth, writes Astro.locals.user, Astro.locals.session, and
Astro.locals.isAuthenticated, then evaluates the current URL against
authConfig from src/config/auth.ts.
Use src/config/auth.ts for route rules that should be enforced consistently by
middleware. Use a route-local check when the rule is specific to one page or API
handler.
The boilerplate also ships with app roles powered by the Better Auth admin
plugin: admin, moderator, user, and banned. Admin URL routes are
reserved for users with the app:administer permission.
Better Auth Boundary
App-owned auth policy lives in src/config/auth.ts:
import { defineAuthConfig } from '@vergekit/core/auth';
export const authConfig = defineAuthConfig({
routes: {
authApiPrefix: '/api/auth',
loginPath: '/login',
protectedExactPaths: ['/dashboard'],
protectedPrefixes: [],
adminExactPaths: ['/admin'],
adminPrefixes: ['/admin/'],
adminPermission: { app: ['administer'] },
},
roles: {
roles: ['admin', 'moderator', 'user', 'banned'],
defaultRole: 'user',
adminRoles: ['admin'],
appStatements: ['access', 'moderate', 'administer'],
roleAppPermissions: {
admin: ['access', 'moderate', 'administer'],
moderator: ['access', 'moderate'],
user: ['access'],
banned: [],
},
bannedSessionError: {
code: 'BANNED_USER',
message: 'Your account has been suspended.',
},
},
browser: {
defaultErrorMessage:
"We couldn't complete that request. Check the fields and try again.",
},
});Foundational runtime behavior lives in @vergekit/core/auth: Better Auth option
construction, admin plugin setup, route access evaluation, role helpers,
sign-out redirect handling, browser form helpers, and Better Auth URL/secret
resolution.
The app still owns the database, schema, and email renderers. src/middleware.ts
and src/pages/api/auth/[...all].ts pass those app-owned pieces into the core
helpers.
Adding Better Auth Plugins
Keep app-specific plugin policy in src/config/auth.ts, but register plugin
instances where Better Auth is created. For a server plugin, export an app-owned
plugin list:
import { organization } from 'better-auth/plugins';
import { defineAuthConfig, type AuthServerPlugin } from '@vergekit/core/auth';
export const authConfig = defineAuthConfig({
// ...
});
export const authServerPlugins = [
organization({
allowUserToCreateOrganization: async (user) =>
user.email.endsWith('@example.com'),
}),
] satisfies AuthServerPlugin[];Then pass that list to the auth API route:
import { authConfig, authServerPlugins } from '@/config/auth';
const authResponse = await createAuthFromEnv({
// ...
authConfig,
additionalPlugins: authServerPlugins,
}).handler(authRequest);If the plugin requires final Better Auth option changes that do not fit a plugin
factory, use the extendOptions escape hatch in the same createAuthFromEnv
call.
Plugins with client APIs also need their Better Auth client plugin wherever the app creates a Better Auth client:
import { createAuthClient } from 'better-auth/client';
import type { BetterAuthClientPlugin } from 'better-auth/client';
import { organizationClient } from 'better-auth/client/plugins';
import { createAuthClientPlugins } from '@vergekit/core/auth';
import { authConfig } from '@/config/auth';
const appAuthClientPlugins = [
organizationClient(),
] satisfies BetterAuthClientPlugin[];
export const authClient = createAuthClient({
plugins: createAuthClientPlugins(authConfig, appAuthClientPlugins),
});For plugins such as Better Auth's organization plugin, also check the rest of the integration surface:
src/config/schema.tsanddrizzle/d1/*migrations for plugin-required tables or columns.src/env.d.tswhen the plugin changes the session or user fields exposed onAstro.locals.src/config/auth-email.tsif the plugin sends transactional auth email that should use app templates or sender defaults.tests/auth/server-config.test.ts,tests/auth/auth-schema.test.ts, andtests/auth/permissions.test.tsfor plugin config, schema, and permission coverage.
Middleware-Protected Routes
Add exact URLs to authConfig.routes.protectedExactPaths when one route needs
authentication:
export const authConfig = defineAuthConfig({
routes: {
protectedExactPaths: ['/dashboard', '/account'],
protectedPrefixes: [],
// ...
},
// ...
});Unauthenticated requests to those paths redirect to /login with the original
destination preserved:
/login?redirectTo=%2FdashboardAdd URL prefixes to authConfig.routes.protectedPrefixes when a group of routes
shares the same auth requirement:
export const authConfig = defineAuthConfig({
routes: {
protectedExactPaths: ['/dashboard'],
protectedPrefixes: ['/settings/', '/api/account/'],
// ...
},
// ...
});Use slash-terminated prefixes when matching a route group. A prefix such as
/settings/ protects /settings/profile without also matching unrelated paths
like /settings-public. If the group index route should also be protected, add
it as an exact path.
Astro filesystem route groups, such as src/pages/(app)/dashboard.astro, do not
appear in request URLs. Add the URL path that the group produces, such as
/dashboard, or a shared URL prefix used by the pages in that group.
Admin Routes
/admin and /admin/* are protected separately from general authenticated
routes. Anonymous users are redirected to /login; authenticated users without
the app:administer permission receive a 403 response.
Change admin route policy in src/config/auth.ts:
export const authConfig = defineAuthConfig({
routes: {
adminExactPaths: ['/admin'],
adminPrefixes: ['/admin/'],
adminPermission: { app: ['administer'] },
// ...
},
// ...
});Change role permissions in src/config/auth.ts:
export const authConfig = defineAuthConfig({
roles: {
roleAppPermissions: {
admin: ['access', 'moderate', 'administer'],
moderator: ['access', 'moderate'],
user: ['access'],
banned: [],
},
// ...
},
// ...
});Route-Local Checks
Per-route auth is useful when a route needs custom behavior, conditional access,
or a JSON 401 response instead of a middleware login redirect. Middleware still
populates locals, so the route can decide for itself.
For a page, redirect from the page frontmatter:
---
const destination = `${Astro.url.pathname}${Astro.url.search}`;
if (!Astro.locals.isAuthenticated) {
return Astro.redirect(
`/login?redirectTo=${encodeURIComponent(destination)}`,
);
}
---For an API route, return an API-shaped response:
import type { APIRoute } from 'astro';
import { jsonFailure, jsonSuccess } from '@vergekit/core/http';
export const POST: APIRoute = async ({ locals }) => {
if (!locals.isAuthenticated) {
return jsonFailure('Unauthorized', { status: 401 });
}
return jsonSuccess({ ok: true });
};This is the right shape for one-off tools and diagnostics, including routes that allow either an authenticated session or a route-specific secret. Keep that logic inside the route when it should not apply globally.
Choosing A Pattern
Use protectedExactPaths for single pages like /dashboard.
Use protectedPrefixes for URL namespaces like /settings/ or
/api/account/. Use the admin route policy for /admin and /admin/*.
Use route-local checks when the response should be custom, especially for API
routes that should return 401 JSON instead of redirecting to the login page.
Use userHasAppPermission from @vergekit/core/auth for local role checks:
import { userHasAppPermission } from '@vergekit/core/auth';
import { authConfig } from '@/config/auth';
if (!userHasAppPermission(authConfig, locals.user, { app: ['moderate'] })) {
return new Response('Forbidden', { status: 403 });
}Keep Better Auth endpoints under /api/auth public. Sign in, sign up, session,
callback, verification, reset, and sign-out requests must be able to reach Better
Auth before a user has an authenticated session.
Tests
When changing middleware-protected route policy, update the route-policy tests:
npm run test -- tests/auth tests/middlewareWhen adding route-local auth, test the route handler directly and pass the
expected locals.isAuthenticated value in the route context.