Skip to content

Authentication

The frontend uses the same Better Auth system as the CMS. There is no second auth service, and a website account has role none: no CMS access. See Authentication concepts.

Login, register and account screens are ordinary pages in your Astro project. The CMS never renders them. The new-site starter includes a complete set under src/pages/account/.

Call the mutating methods from the browser through the same-origin proxy:

import { browserCms, KenresoftApiError } from '../lib/kenresoft';
try {
const result = await browserCms.auth.signIn({ email, password });
if (result.twoFactorRequired) {
await browserCms.auth.twoFactor.verifyTotp({ code });
}
window.location.href = '/account';
} catch (err) {
if (err instanceof KenresoftApiError && err.code === 'EMAIL_NOT_VERIFIED') {
// a fresh verification link has already been sent
}
}

For a server-rendered “is this visitor signed in?”, forward the cookie:

const session = await cmsForRequest(Astro.request).auth.getSession(); // null when signed out
Method Endpoint Notes
signUp({ email, password, name, callbackUrl?, turnstileToken? }) POST /api/v1/auth/sign-up/email Emails a verification link. Resolves { requiresEmailVerification: true } with no session
signIn({ email, password, rememberMe?, callbackUrl? }) POST /api/v1/auth/sign-in/email Resolves { twoFactorRequired, user }. Throws 401 INVALID_EMAIL_OR_PASSWORD or 403 EMAIL_NOT_VERIFIED (a fresh link is sent)
signOut() POST /api/v1/auth/sign-out Idempotent
getSession() GET /api/v1/auth/get-session { user, session }, or null when signed out
verifyEmail({ token }) GET /api/v1/auth/verify-email For the page your callbackUrl points at
resendVerificationEmail({ email, callbackUrl? }) POST /api/v1/auth/send-verification-email Always returns the same generic message
requestPasswordReset({ email, redirectUrl? }) POST /api/v1/public/password-reset/request Always the same generic message. The emailed link becomes <redirectUrl>?token=…
resetPassword({ token, newPassword }) POST /api/v1/public/password-reset/confirm 400 for an invalid or expired token
changePassword({ currentPassword, newPassword, revokeOtherSessions? }) POST /api/v1/auth/change-password Needs a session. Signs out other devices by default
twoFactor.enable, verifyTotp, verifyBackupCode, disable, generateBackupCodes /api/v1/auth/two-factor/* TOTP and backup codes only

callbackUrl and redirectUrl must be origins listed in the CMS CORS_ORIGINS. Password-reset and verification responses are deliberately generic so they never reveal whether an account exists.

POST /api/v1/auth/sign-up/email
POST /api/v1/auth/sign-in/email
POST /api/v1/auth/sign-out
GET /api/v1/auth/get-session
GET /api/v1/auth/verify-email
POST /api/v1/auth/send-verification-email
POST /api/v1/auth/change-password
POST /api/v1/auth/two-factor/{enable,verify-totp,verify-backup-code,disable,generate-backup-codes}
POST /api/v1/public/password-reset/request
POST /api/v1/public/password-reset/confirm
  • Auth POST routes are limited to 10 per 60 seconds per client IP. Password reset is limited to 3. See Rate limiting.
  • Sign-in requires a verified email, so the CMS needs a working email provider before launch.
  • Optional bot protection on sign-up: create a Turnstile widget at dash.cloudflare.com → Turnstile, set it up on the API with pnpm run update -- --turnstile (which stores the secret key and the site key), and pass turnstileToken to signUp. When set, the API returns 400 TURNSTILE_REQUIRED without a token, 403 TURNSTILE_FAILED for a bad or reused one, and 503 if Cloudflare cannot be reached. Tokens are single use. Your frontend needs the site (public) key to show the widget: read it from the API with system.status() (turnstileSiteKey, null when not set), or set PUBLIC_TURNSTILE_SITE_KEY in the site yourself; see Environment variables.

cms.commerce.customerAuth and commerce.customer.changePassword() call this same auth API, so a session created either way is the same session the cart, checkout and account routes read. See Commerce.