Mobile apps
You can build a companion iOS or Android app on top of this project and let Nuxt handle authentication, accounts, and subscriptions — the app talks to the same API the website uses. It does not matter how the app is built: Ionic/Capacitor, React Native, Flutter, or fully native all work the same way, and so does any non-mobile client such as a CLI or another backend service.
The one difference from the web app is how the session travels. Phones authenticate with email OTP and then carry a bearer token instead of a session cookie.
Nothing extra needs to be installed on the Nuxt side — the bearer() plugin, the CORS middleware, and
the OTP endpoints are already part of the template. This page covers what to configure on the server,
and what the app has to do.
How the flow works
- The app posts an email address to the OTP endpoint; the server emails a 6-digit code.
- The app posts the email and code to the sign-in endpoint. The first successful code creates the account, so signup and login are the same call.
- The response carries a
set-auth-tokenheader. That value is the session token. - The app stores the token and sends
Authorization: Bearer <token>on every later request. - Sessions last 60 days and refresh in the background every 24 hours.
The website gets a cookie from the same endpoints, and the two mechanisms coexist. An app should ignore cookies entirely and rely on the bearer token.
Configure the Nuxt backend
Environment variables
# The public origin of this API, exactly as the app will call it
BETTER_AUTH_URL=https://api.yourdomain.com
# Only needed for browser-based clients on another origin (see below)
BETTER_AUTH_TRUSTED_ORIGINS=https://app.yourdomain.com
# Only needed behind more than one proxy hop
BETTER_AUTH_TRUSTED_PROXIES=
# Without this, OTP codes are logged to the server console instead of emailed
RESEND_API_KEY=re_...
BETTER_AUTH_URL must match the origin the app requests, including scheme and any port.
RESEND_API_KEY is optional for local development only. Without an email provider, no one can log in
from a real device — the code only appears in the server console.When trusted origins are needed
It depends on whether the app runs in a browser engine:
- Fully native iOS/Android, React Native, a CLI, or a server-to-server integration send no
Originheader, so CORS never applies. LeaveBETTER_AUTH_TRUSTED_ORIGINSempty. - Ionic/Capacitor apps run in a WebView and do send one.
capacitor://localhost,ionic://localhost, andhttp://localhostare always trusted, in production too, so a standard Capacitor build needs no configuration. - A web build of the app hosted on another domain needs its origin added to
BETTER_AUTH_TRUSTED_ORIGINS.
server/middleware/00-cors.ts answers preflight requests for trusted origins and — importantly —
sets Access-Control-Expose-Headers: set-auth-token, without which a WebView app cannot read the
token out of the sign-in response.
Rate limits worth knowing about
Limits are enforced per client IP in server/utils/auth.ts:
| Scope | Limit |
|---|---|
| Every auth endpoint | 100 requests / 60s |
/email-otp/send-verification-otp | 20 requests / 60s |
/sign-in/email-otp | 30 requests / 60s |
| Per email address (Postgres) | 5 OTP sends / 15 minutes |
Mobile users behind carrier NAT share an IP, which is why the per-IP numbers are generous and the per-email throttle does the real work of protecting an inbox. That email counter is stored in Postgres so it is shared across every server instance.
A server-to-server integration is the opposite case: every call arrives from one egress IP and shares
a single bucket. If you are integrating another service rather than end-user devices, raise the
relevant entry in rateLimit.customRules.
BETTER_AUTH_TRUSTED_PROXIES —
otherwise the client IP cannot be resolved and all clients share one bucket. See
environment variables.Configure the app
Ionic, Capacitor, or any TypeScript app
Use better-auth's own client, pointed at your API and taught to capture and replay the token. The token has to be read back synchronously, so load it into memory once at startup rather than reading device storage on every request:
import { createAuthClient } from 'better-auth/client'
import { emailOTPClient } from 'better-auth/client/plugins'
import { Preferences } from '@capacitor/preferences'
const TOKEN_KEY = 'bearer_token'
let token = ''
export async function restoreToken() {
const { value } = await Preferences.get({ key: TOKEN_KEY })
token = value ?? ''
}
export const authClient = createAuthClient({
baseURL: 'https://api.yourdomain.com', // '/api/auth' is appended for you
plugins: [emailOTPClient()],
fetchOptions: {
auth: {
type: 'Bearer',
token: () => token,
},
onSuccess: async ctx => {
// Sent on sign-in and whenever the session is refreshed
const next = ctx.response.headers.get('set-auth-token')
if (next) {
token = next
await Preferences.set({ key: TOKEN_KEY, value: next })
}
},
},
})
Call restoreToken() before the first request, then sign in:
await authClient.emailOtp.sendVerificationOtp({ email, type: 'sign-in' })
await authClient.signIn.emailOtp({ email, otp })
marketingOptIn: true alongside email and otp
stores the preference on the UserData row created for new accounts.Swift, Kotlin, Flutter, or anything else
Nothing about the backend is TypeScript-specific — it is plain JSON over HTTP. Request a code:
curl -X POST https://api.yourdomain.com/api/auth/email-otp/send-verification-otp \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","type":"sign-in"}'
Exchange it for a token — -i so you can see the header:
curl -i -X POST https://api.yourdomain.com/api/auth/sign-in/email-otp \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","otp":"123456"}'
# set-auth-token: eyJhbGciOi...
Then use it:
curl https://api.yourdomain.com/api/auth/get-session \
-H 'Authorization: Bearer eyJhbGciOi...'
Endpoint reference
| Purpose | Request |
|---|---|
| Request a code | POST /api/auth/email-otp/send-verification-otp { email, type: 'sign-in' } |
| Sign in or sign up | POST /api/auth/sign-in/email-otp { email, otp } → read set-auth-token |
| Current session | GET /api/auth/get-session |
| Sign out | POST /api/auth/sign-out (invalidates the token server-side) |
| Update profile | POST /api/auth/update-user { name } |
| Request email change | POST /api/auth/email-otp/request-email-change { newEmail } |
| Confirm email change | POST /api/auth/email-otp/change-email { newEmail, otp } |
| Profile, preferences, subscription | GET /api/me |
| Entitlement | GET /api/subscription |
| Delete account | POST /api/account/delete { reason? } |
| Undo deletion | POST /api/account/cancel-deletion |
Every request except the first two requires the Authorization header.
GET /api/subscription returns a snake_case projection intended for mobile apps, and fails
closed — a user without an active subscription gets no plan at all rather than a default one:
{
"plan": "pro",
"status": "active",
"current_period_end": "2026-08-01T00:00:00.000Z",
"processor": "apple",
"processor_price_id": "…",
"will_renew": true
}
processor is stored as revenuecat_ios / revenuecat_android and mapped to apple / google on
the way out. When there is no active subscription, plan is null, status is "inactive", and
will_renew is false.
Your own endpoints
Protected routes work with bearer tokens automatically — requireAuth(event) hands the incoming
request headers to better-auth, so a cookie and a bearer token are equally valid:
export default defineEventHandler(async event => {
const userId = await requireAuth(event)
return listNotes(userId)
})
The same is true of requireAdminAuth and requireSubscription.
Gotchas
- Always re-read
set-auth-token. It is not only returned at sign-in; a refreshed session issues a new token, and the old one eventually stops working. - Send
Content-Type: application/jsonon every POST. Some HTTP clients omit it when the body is empty, and better-auth rejects those requests. The catch-all handler patches this for/sign-outonly. type: 'sign-in'is what creates accounts. Theemail-verificationtype belongs to the email change flow.- Sign-out is server-side. Discarding the token locally leaves the session valid until it expires; call the endpoint.
- Account deletion is a grace period, not an immediate wipe.
POST /api/account/deletereturns adeletionDate, and the account can be restored until then.
Local development
Run the server with pnpm dev and point the app at your machine's LAN address rather than
localhost, since localhost on a phone means the phone itself. Set BETTER_AUTH_URL to the same
address:
BETTER_AUTH_URL=http://192.168.1.20:3000
Without RESEND_API_KEY, OTP codes are printed to the server console:
ℹ [auth] OTP for [email protected] (sign-in): 123456
Checklist
-
BETTER_AUTH_URLmatches the origin the app calls -
RESEND_API_KEYset, so real devices receive codes -
BETTER_AUTH_TRUSTED_ORIGINSset only if a WebView or web build lives on another origin -
BETTER_AUTH_TRUSTED_PROXIESset if the deployment has more than one proxy hop - App stores
set-auth-tokenand refreshes it from later responses - App sends
Authorization: Bearer …andContent-Type: application/json