Better Auth
Translate Better Auth error messages with CDN-based localization
@better-i18n/server/providers/better-auth creates a Better Auth plugin that automatically translates error messages (like "Invalid email or password") into your users' language — fetched from the CDN at runtime.
No redeployment needed. Unlike bundled localization packages, translations are served from the Better i18n CDN. Add a new language or fix a typo from the dashboard — it's live within 60 seconds.
Setup
Install the package
bun add @better-i18n/servernpm install @better-i18n/serverpnpm add @better-i18n/serveryarn add @better-i18n/serverCreate the i18n singleton
This instance is shared between Better Auth and your server middleware (Hono, Express, etc.) — one CDN cache for everything.
import { createServerI18n } from "@better-i18n/server";
export const i18n = createServerI18n({
project: "my-org/my-project", // your Better i18n project
defaultLocale: "en",
});Add the provider to Better Auth
import { betterAuth } from "better-auth";
import { createBetterAuthProvider } from "@better-i18n/server/providers/better-auth";
import { i18n } from "./i18n";
export const auth = betterAuth({
// ...your config
plugins: [
createBetterAuthProvider(i18n),
// ...other plugins
],
});That's it. The provider hooks into every Better Auth error response, detects the locale from the Accept-Language header, and translates the error message.
Add translations in the dashboard
- Go to your project in Better i18n
- Create a namespace called
auth - Add translation keys matching Better Auth error codes (e.g.,
INVALID_EMAIL_OR_PASSWORD) - Translate into your target languages
- Publish — translations are live instantly
Seed keys with AI. Use the Better i18n MCP server to bulk-create all default keys in one prompt:
Create all keys from Better Auth's core error codes in the "auth" namespace. The keys are: INVALID_EMAIL_OR_PASSWORD, USER_NOT_FOUND, EMAIL_NOT_VERIFIED, SESSION_EXPIRED, PASSWORD_TOO_SHORT, PASSWORD_TOO_LONG, USER_ALREADY_EXISTS, INVALID_TOKEN, TOKEN_EXPIRED, ACCOUNT_NOT_FOUND. Set English values to their default messages.
How It Works
When Better Auth returns an error (e.g., invalid login), the provider:
- Detects if the response is an
APIErrorwith abody.code(e.g.,INVALID_EMAIL_OR_PASSWORD) - Resolves the user's locale from the
Accept-Languageheader - Fetches translations from the CDN — all namespaces are served in a single
translations.jsonper locale, and the provider scopes to theauthnamespace automatically - Replaces
body.messagewith the translated string
If a translation is missing, the original English message is preserved and a warning is logged — auth never breaks because of a missing translation.
Client (Accept-Language: tr) → Better Auth → APIError
→ Provider: detectLocale("tr")
→ CDN: /{org}/{project}/tr/translations.json → { "auth": { "INVALID_EMAIL_OR_PASSWORD": "..." } }
→ Response: { "code": "INVALID_EMAIL_OR_PASSWORD", "message": "Geçersiz e-posta veya şifre" }Translations are cached in memory (60s TTL). After the first request, subsequent lookups are instant — no extra CDN calls per auth error.
Locale Detection
By default, the provider reads the Accept-Language header. For most apps, you'll want cookie-based detection instead — it uses the locale the user actually chose in your app, not their browser/OS language.
Cookie-based (recommended)
Pair with the provider's localeCookie prop — same cookie name on both sides:
createBetterAuthProvider(i18n, {
localeCookie: "locale",
// reads the "locale" cookie from the request
// falls back to Accept-Language if cookie is missing
})// Client writes the cookie automatically:
<BetterI18nProvider localeCookie> {/* default cookie name: "locale" */}
<App />
</BetterI18nProvider>Custom callback
For advanced cases (database, user profile, custom header):
createBetterAuthProvider(i18n, {
getLocale: async ({ headers }) => {
const userId = getUserIdFromSession(headers);
return await db.users.getLocale(userId) ?? "en";
},
})Options
| Option | Type | Default | Description |
|---|---|---|---|
namespace | string | "auth" | CDN namespace for auth translations |
localeCookie | string | — | Cookie name to read locale from (pairs with provider's localeCookie prop) |
getLocale | (ctx) => string | Promise<string> | Accept-Language detection | Custom locale resolver (overrides all other detection) |
warnOnMissingKeys | boolean | true | Log warnings for untranslated error codes |
Detection priority: getLocale callback > localeCookie > Accept-Language header > defaultLocale
Default Error Codes
The provider exports DEFAULT_AUTH_KEYS — all Better Auth core error codes with English defaults. Create these keys in your project's auth namespace, then translate them from the dashboard.
Seed all keys with AI. Copy the prompt below into Claude, ChatGPT, or any AI agent with the Better i18n MCP server connected:
Import all Better Auth error codes into my project's "auth" namespace using
DEFAULT_AUTH_KEYSfrom@better-i18n/server/providers/better-auth. Create all 48 keys with their English defaults, then translate them into my project's target languages.
Full Key Reference (48 keys)
| Key | Default English Message |
|---|---|
| Authentication | |
INVALID_EMAIL_OR_PASSWORD | Invalid email or password |
INVALID_PASSWORD | Invalid password |
INVALID_EMAIL | Invalid email |
INVALID_TOKEN | Invalid token |
TOKEN_EXPIRED | Token expired |
EMAIL_NOT_VERIFIED | Email not verified |
EMAIL_ALREADY_VERIFIED | Email is already verified |
EMAIL_MISMATCH | Email mismatch |
| User Management | |
USER_NOT_FOUND | User not found |
USER_ALREADY_EXISTS | User already exists. |
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL | User already exists. Use another email. |
INVALID_USER | Invalid user |
USER_EMAIL_NOT_FOUND | User email not found |
USER_ALREADY_HAS_PASSWORD | User already has a password. Provide that to delete the account. |
FAILED_TO_CREATE_USER | Failed to create user |
FAILED_TO_UPDATE_USER | Failed to update user |
FAILED_TO_GET_USER_INFO | Failed to get user info |
| Session | |
SESSION_EXPIRED | Session expired. Re-authenticate to perform this action. |
SESSION_NOT_FRESH | Session is not fresh |
FAILED_TO_CREATE_SESSION | Failed to create session |
FAILED_TO_GET_SESSION | Failed to get session |
| Password | |
PASSWORD_TOO_SHORT | Password too short |
PASSWORD_TOO_LONG | Password too long |
PASSWORD_ALREADY_SET | User already has a password set |
CREDENTIAL_ACCOUNT_NOT_FOUND | Credential account not found |
| Account & Social | |
ACCOUNT_NOT_FOUND | Account not found |
SOCIAL_ACCOUNT_ALREADY_LINKED | Social account already linked |
LINKED_ACCOUNT_ALREADY_EXISTS | Linked account already exists |
FAILED_TO_UNLINK_LAST_ACCOUNT | You can't unlink your last account |
PROVIDER_NOT_FOUND | Provider not found |
ID_TOKEN_NOT_SUPPORTED | id_token not supported |
| Email Verification | |
VERIFICATION_EMAIL_NOT_ENABLED | Verification email isn't enabled |
EMAIL_CAN_NOT_BE_UPDATED | Email can not be updated |
FAILED_TO_CREATE_VERIFICATION | Unable to create verification |
| URL Validation | |
INVALID_ORIGIN | Invalid origin |
INVALID_CALLBACK_URL | Invalid callbackURL |
INVALID_REDIRECT_URL | Invalid redirectURL |
INVALID_ERROR_CALLBACK_URL | Invalid errorCallbackURL |
INVALID_NEW_USER_CALLBACK_URL | Invalid newUserCallbackURL |
MISSING_OR_NULL_ORIGIN | Missing or null Origin |
CALLBACK_URL_REQUIRED | callbackURL is required |
| Security | |
CROSS_SITE_NAVIGATION_LOGIN_BLOCKED | Cross-site navigation login blocked. This request appears to be a CSRF attack. |
| Validation | |
VALIDATION_ERROR | Validation Error |
MISSING_FIELD | Field is required |
FIELD_NOT_ALLOWED | Field not allowed to be set |
ASYNC_VALIDATION_NOT_SUPPORTED | Async validation is not supported |
BODY_MUST_BE_AN_OBJECT | Body must be an object |
METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED | POST method requires deferSessionRefresh to be enabled in session config |
You can also access these programmatically:
import { DEFAULT_AUTH_KEYS } from "@better-i18n/server/providers/better-auth";
// 40+ keys with English defaults
Object.entries(DEFAULT_AUTH_KEYS).forEach(([key, message]) => {
console.log(`${key}: ${message}`);
});This table covers Better Auth core error codes. Plugin-specific codes (admin, two-factor, email-otp, organization) can be added as additional keys in your namespace.
Sharing with Hono Middleware
The i18n singleton is designed to be shared. Use the same instance for both Better Auth and your Hono API:
import { Hono } from "hono";
import { betterI18n } from "@better-i18n/server/hono";
import { i18n } from "./i18n"; // same singleton
const app = new Hono<{
Variables: { locale: string; t: Translator };
}>();
app.use("*", betterI18n(i18n)); // Hono routes get c.get("t")
// Better Auth also uses the same i18n → shared CDN cache, zero extra fetches