Mobile apps

Use this Nuxt app as the auth backend for an iOS or Android app (Ionic/Capacitor, React Native, native) or another service

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

  1. The app posts an email address to the OTP endpoint; the server emails a 6-digit code.
  2. 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.
  3. The response carries a set-auth-token header. That value is the session token.
  4. The app stores the token and sends Authorization: Bearer <token> on every later request.
  5. 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 Origin header, so CORS never applies. Leave BETTER_AUTH_TRUSTED_ORIGINS empty.
  • Ionic/Capacitor apps run in a WebView and do send one. capacitor://localhost, ionic://localhost, and http://localhost are 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:

ScopeLimit
Every auth endpoint100 requests / 60s
/email-otp/send-verification-otp20 requests / 60s
/sign-in/email-otp30 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.

If your deployment sits behind a CDN plus a platform router, also set 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:

auth-client.ts
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 })
The sign-in body accepts extra fields. Sending 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

PurposeRequest
Request a codePOST /api/auth/email-otp/send-verification-otp { email, type: 'sign-in' }
Sign in or sign upPOST /api/auth/sign-in/email-otp { email, otp } → read set-auth-token
Current sessionGET /api/auth/get-session
Sign outPOST /api/auth/sign-out (invalidates the token server-side)
Update profilePOST /api/auth/update-user { name }
Request email changePOST /api/auth/email-otp/request-email-change { newEmail }
Confirm email changePOST /api/auth/email-otp/change-email { newEmail, otp }
Profile, preferences, subscriptionGET /api/me
EntitlementGET /api/subscription
Delete accountPOST /api/account/delete { reason? }
Undo deletionPOST /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:

server/api/notes.get.ts
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/json on 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-out only.
  • type: 'sign-in' is what creates accounts. The email-verification type 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/delete returns a deletionDate, 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_URL matches the origin the app calls
  • RESEND_API_KEY set, so real devices receive codes
  • BETTER_AUTH_TRUSTED_ORIGINS set only if a WebView or web build lives on another origin
  • BETTER_AUTH_TRUSTED_PROXIES set if the deployment has more than one proxy hop
  • App stores set-auth-token and refreshes it from later responses
  • App sends Authorization: Bearer … and Content-Type: application/json
For how the auth system is configured, see the authentication guide.