---
name: applicaster-custom-login-ui
description: Use when building an Applicaster "custom UI" login flow — a customer-hosted website (shown in the app's webview on mobile) that runs your own login UI/UX and hands the tokens back to the app. Use it whenever the default Login Flow UI isn't enough — for full control over the login/registration screens, or to add third-party providers (Apple, Google, Facebook, SMS) the default flow doesn't support. Covers the exact postMessage handoff, the refresh-token URL, and the delete-account URL.
---

# Applicaster Custom UI Login Flow

## Overview

Applicaster's default Login Flow ships its own UI (email/password only). When that
isn't enough — you want **full control of the login/registration UI/UX**, or you need a
**third-party provider** (Apple / Google / Facebook / mobile SMS) the default doesn't
support — you host your own login website. The app loads it in a **webview (mobile
only)** and your page hands the tokens back via `postMessage`.

Your job is three things:

1. **A login website** — authenticates the user (however you like) and posts the tokens back.
2. **A Refresh token URL** — backend endpoint the app polls to keep the session alive.
3. **A Delete account URL** — backend endpoint that deletes the user from their token.

## Before you build: ask about the UI/UX

The token handoff is fixed, but the screens around it are entirely the customer's
choice.

**First, ask whether there's a design spec** — a Figma file, mockups, a brand guide, or
a written spec. If there is, follow it and only ask about the gaps it leaves. If there
isn't, walk through the questions below before writing any UI — don't assume defaults.

- **Entry flow** — is there a splash/intro screen before login, or does the app land
  straight on the auth form?
- **Onboarding / FTUE** — any first-time-user experience (carousel, feature tour, value
  props) shown before the login screen? Should it replay, or show once and be remembered?
- **Login vs registration first** — which screen opens by default, and how does the user
  switch between them (toggle, tabs, links)?
- **Auth methods** — email/password, and/or which providers (Apple, Google, Facebook,
  SMS)? Order and prominence of the buttons?
- **Registration fields** — which of firstName, lastName, email, password (and any
  custom fields)? Which are required? Terms-of-use / marketing opt-in checkboxes?
- **Login fields** — email/password, or provider-only? Show/hide password toggle?
  "Forgot password" entry point?
- **Field validation** — what rules per field? Email format; password strength (min
  length, character classes, confirm-password match); required vs optional; allowed
  characters. And how are errors surfaced — inline per field or a form-level banner, and
  on blur or on submit?
- **Background & branding** — video background, image, or solid color? Logo, brand
  colors, fonts, dark/light?
- **Copy & legal** — headline/subtitle text, links to Terms and Privacy, required
  consent wording, localization / multiple languages?
- **Guest / skip** — is there a "continue as guest" or skip-login path?

Also think about what else the specific customer's product implies (e.g. age gate,
promo-code entry, region selection) and ask about it. Capture the answers, then build
the screens around the same handoff described below.

## 1. The token handoff (the one thing everyone gets wrong)

Once your page has authenticated the user, send **exactly one** `postMessage` call with
these required fields:

```js
window.ReactNativeWebView.postMessage(
  JSON.stringify({
    access_token: accessToken,   // string
    refresh_token: refreshToken, // string
    expires_in: expiresIn,       // number, SECONDS until the access token expires
    status: 200,
    refresh_url: refreshUrl,     // the HTTPS URL of your Refresh endpoint (item 2), NOT a token
  })
);
```

### Passing extra values to the app

Your backend's login response can contain anything — `id_token`, custom claims, profile
fields, whatever your provider returns. The app ignores top-level keys it doesn't know.
**Any value you want the native app to keep must go under `extensions`, at the fixed
`quick-brick-login-flow` key** — that namespace is where the native side reads
app-specific values from:

```js
window.ReactNativeWebView.postMessage(
  JSON.stringify({
    access_token: accessToken,
    refresh_token: refreshToken,
    expires_in: expiresIn,
    status: 200,
    refresh_url: refreshUrl,
    extensions: {
      storage_keys: {
        // fixed key — the native app reads app-specific values from here
        "quick-brick-login-flow": { user_id, email, id_token /*, …whatever the app needs */ },
      },
    },
  })
);
```

If your backend already returns a correctly-shaped `extensions` object, pass it through
unchanged rather than rebuilding it by hand, so you don't drop anything.

**Ask about extra params.** Before finishing, ask the implementer: *"Does your app need
any extra parameters passed back on login?"* — ask whether *additional* values are
needed, not whether the standard fields are. Put whatever they name under
`extensions.storage_keys["quick-brick-login-flow"]`.

### Detect the runtime context (app vs desktop browser)

The page runs in two places: inside the app's **webview** (real logins) and in a plain
**desktop browser** (development, or a user opening the URL directly). Detect which by the
presence of `window.ReactNativeWebView` — it exists **only** inside the webview:

```js
function inAppWebview() {
  return typeof window !== "undefined" && !!window.ReactNativeWebView?.postMessage;
}
```

Two things to get right:

- **Check after mount, not at module load.** The native bridge is injected during page
  load and may be absent on the first render. Detect it in a post-mount hook
  (`useEffect`, `DOMContentLoaded`, or right before the handoff) — never cache the result
  at import time.
- **Behave differently per context.** In the webview, do the `postMessage` handoff. In a
  desktop browser there is nothing to hand off to, so don't silently no-op — log/show the
  payload for development, and consider telling the user this login only completes inside
  the app.

```js
if (inAppWebview()) {
  window.ReactNativeWebView.postMessage(JSON.stringify(payload));
} else {
  console.warn("Not in the app webview — login can't complete here. Payload:", payload);
}
```

## 2. Refresh token URL

The app periodically POSTs the `refresh_token` to keep the user signed in.

- Request: `POST` with JSON body `{ "refresh_token": "<REFRESH_TOKEN>" }`
- Success: `200` with `{ "access_token", "refresh_token", "expires_in" }` (same shape as the handoff)
- Failure: `403`

## 3. Delete account URL

The app POSTs here with the user's token **base64url**-encoded in a `ctx` **query param**.
It is base64**url** (`-`/`_`, no padding), not standard base64 — decoding it as plain
base64 breaks on JWTs that contain `-` or `_`:

```js
// server-side (Node)
const ctx = JSON.parse(Buffer.from(req.query.ctx, "base64url").toString("utf8"));
const token = ctx["quick-brick-login-flow.access_token"]; // flat key, not nested
// identify the user from `token` (JWT decode is common) and delete the account
```

In other languages use the URL-safe base64 decoder (Python `base64.urlsafe_b64decode`,
PHP `sodium_base642bin(..., BASE64_VARIANT_URLSAFE_NO_PADDING)`, Go `base64.RawURLEncoding`).

- Success: `200`
- Failure: status `>= 400`

## Hosting notes

- **Mobile only** — TV is not supported for custom UI.
- Host on **your own servers over HTTPS**.
- The app renders your page in a webview — it's your job to make it look right on device
  (responsive, safe-area aware, no fixed desktop widths).

## Verify it works

Don't call the integration done until you've verified it in two layers — the page on its
own, then wired into the app through Zapp.

### 1. Locally, before Zapp

The webview bridge doesn't exist in a desktop browser, so simulate it. In the browser
console, install a fake bridge **before** logging in, then log in:

```js
window.ReactNativeWebView = { postMessage: (m) => console.log("HANDOFF →", m) };
```

Confirm, on a successful login, that **exactly one** `HANDOFF →` line prints and it is a
single valid JSON string (not `undefined`) containing `access_token`, `refresh_token`,
`expires_in` (seconds), `refresh_url` (an `https://` URL), `status: 200`, and any backend
`extensions` you received. Then exercise the two backend URLs directly:

- `POST` your refresh URL with `{ "refresh_token": "..." }` → expect `200` + fresh tokens.
- `GET`/`POST` your delete URL with `?ctx=<base64url of {"quick-brick-login-flow.access_token":"<token>"}>`
  → expect the account deleted and `200`.

### 2. End-to-end in the app, via Zapp

Point Zapp's Login Flow plugin at your hosted site and run the app:

1. Install the **Login Flow** plugin (debug mode off) and add the login flow screen to your
   mobile layout.
2. Set the **Login Endpoint** to **your custom UI URL** — this is where you "set the URL";
   for a custom UI it's your own HTTPS site, not the Applicaster default endpoint.
3. Fill in the **API URLs** (login / register / reset / refresh) and the **Delete account
   Endpoint** (a Zapp Endpoint with `POST` + context key `quick-brick-login-flow.access_token`).
4. Add the login screen as a **pre-hook** on a screen (e.g. the player) so opening it
   triggers login.
5. Build/run on a **mobile device** and check the full loop: your UI loads in the webview →
   login succeeds and the app proceeds (handoff worked) → the session survives an app
   relaunch (refresh worked) → account deletion works.

Full configuration steps and screenshots are in the Zapp Mobile Login guide:
<https://docs.applicaster.com/using-zapp/auth/mobile-login#web-login-flow>. For the API
contract behind the Login Flow, see
<https://docs.applicaster.com/integrations/auth/implementing-login-flow>.

## Worked example

`example/index.html` is a self-contained, no-build login page that fakes a provider
login, then does the guarded `postMessage` handoff. Adapt the `authenticate()` function
to your real provider and point `REFRESH_URL` at your backend. Open it in a browser to
develop; it logs the payload to the console when not inside the app webview.
