How to Add Authentication to a Web or Flutter App Without Writing an Auth Server [Full Repo]

A web page and a Flutter app get signup, login, logout and password reset through six REST calls to a managed backend, with no server code that touches a password. We measured every call, including the e-mail that never arrived, and why.

The six REST calls that make up signup, login, session check, password reset, logout and a rejected session, each with the status code and time we measured on September 17, 2026, next to the password-reset e-mail that arrived one second after the request

Every app with accounts needs the same five things: a way to sign up, a way to log in, a way to stay logged in, a way to log out, and a way back in when the password is gone. None of them is your product. All of them are where a mistake costs you the most.

The usual answer is an auth server: a process you write and run that hashes passwords, mints tokens, sends e-mails and rotates secrets. This post is about not writing it. A managed backend already has a users class with hashing, sessions, e-mail and a hosted reset page. Your app talks to it over REST, from a browser or from a phone.

We built it twice on the same backend: a plain HTML page with 91 lines of JavaScript, and a Flutter app with 58 lines of Dart for the API. Both make the same six calls. Everything below was measured on September 17, 2026, on Back4app, including the one e-mail that did not arrive.

Get the working code: the companion repo is at github.com/templates-back4app/user-auth-starter — web/, flutter/, cloud/main.js, and auth-check.sh, which walks the whole flow from curl and prints the status codes and timings you will see below.

What does an auth server actually have to get right?

More than it looks. Here is the list a hand-written one has to cover before it is safe to ship, and where each item lands when a managed backend does it.

The auth server’s jobWhy you cannot skip itWho does it here
Hash passwords, never store themOne leaked table is every accountBackend: the password column shows (hidden) even to you
Mint a token per login and check it per requestCookies and JWTs are where most bugs liveBackend: _Session class, X-Parse-Session-Token header
Revoke a token on logout“Logged out” must mean itBackend: POST /logout
Reject duplicates and bad inputTwo accounts, one e-mailBackend: codes 202 / 203, plus your hook
Send the reset e-mail with a one-time linkThe link is a credentialBackend: [email protected], hosted page
Verify the e-mail addressOr anyone can sign up as youBackend, after a card is validated
Lock out brute forcePassword guessing at network speedDashboard setting (paid) or your hook

Nothing in that table runs on your machine. What does run on your machine is a form and six fetch calls.

The app never sees a password hash, a token secret or a mail server. It sees JSON.

Which six REST calls replace the auth server?

The same six from the web page, from Flutter and from curl. Every call sends two headers that identify the app: X-Parse-Application-Id and a client key (X-Parse-JavaScript-Key on the web, X-Parse-Client-Key in Flutter). A third header, X-Parse-Revocable-Session: 1, asks for tokens that POST /logout can revoke.

Architecture: a web page and a Flutter app make the same six REST calls to the Back4app backend, which keeps the _User and _Session classes, runs a beforeSave hook on _User, and sends the password-reset e-mail whose link opens a hosted choose-password page

CallWhat it doesMeasured (curl, then browser)
POST /usersCreates the account, returns sessionToken201 · 856 ms · 597 ms
POST /loginChecks the password, returns a new sessionToken200 · 544 ms · 245 ms
GET /users/meValidates the token, returns the user200 · 861 ms
POST /requestPasswordResetSends the reset e-mail200 {} · e-mail 1 s later
POST /verificationEmailRequestSends the verification e-mail (when enabled)200 {} · no e-mail on a free account
POST /logoutRevokes the calling token200 · next GET /users/me → 209

The first curl numbers include the backend waking up and the TLS handshake from a terminal. The browser numbers are what a user feels on the second request.

Measured · September 17, 2026 · browser → Back4app backend, USA East
597 mssignup, POST /users
→
245 mslogin, POST /login
1 sreset e-mail arrival

How we measured: performance.now() around each fetch in the web page, and the timings auth-check.sh prints for curl, one run each on September 17, 2026, from a laptop to the USA East backend. The curl numbers include the TLS handshake; the browser numbers are a second request on a warm connection. The e-mail time is the inbox’s received time minus the request time.

How does the web page sign up, log in and log out?

One HTML file with three forms and a logged-in panel, and one script. The script keeps a single piece of state, the session token, in sessionStorage: gone when the tab closes, unreadable from another origin. Here is the part that matters, the api helper and the signup handler:

// Stack: vanilla JavaScript (browser) | File: web/app.js
const BASE = window.BACKEND_URL ?? "https://parseapi.back4app.com";
const HEADERS = {
  "X-Parse-Application-Id": window.APP_ID,
  "X-Parse-JavaScript-Key": window.JS_KEY,      // a client key: it identifies the app, not the user
  "X-Parse-Revocable-Session": "1",
  "Content-Type": "application/json",
};

const session = {
  get: () => sessionStorage.getItem("sessionToken"),
  set: (t) => sessionStorage.setItem("sessionToken", t),
  clear: () => sessionStorage.removeItem("sessionToken"),
};

async function api(method, path, body, token) {
  const headers = { ...HEADERS };
  if (token) headers["X-Parse-Session-Token"] = token;
  const r = await fetch(BASE + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
  const data = r.status === 204 ? {} : await r.json();
  if (!r.ok) throw Object.assign(new Error(data.error ?? r.statusText), { code: data.code, status: r.status });
  return data;
}

$("#signup").addEventListener("submit", async (e) => {
  e.preventDefault();
  try {
    const t0 = performance.now();
    const u = await api("POST", "/users", fields(e.target));       // 201 {objectId, createdAt, sessionToken}
    session.set(u.sessionToken);
    status(`signed up in ${Math.round(performance.now() - t0)} ms — check your inbox for the verification e-mail`, "ok");
    e.target.reset(); showMe();
  } catch (err) { status(`${err.code} ${err.message}`, "err"); }
});

Login is the same handler against /login. Logout calls /logout with the token and then clears it, in that order: the server forgets the token, then the tab does. showMe() runs on every page load and calls GET /users/me with whatever token is stored, so a revoked or expired token is detected on the next visit, not silently trusted.

The auth starter web page before any request: three forms, Sign up, Log in and Forgot your password, each with its own button and one empty status bar above them

We filled in the signup form. The status bar reported signed up in 597 ms, and the panel below it is the answer to GET /users/me: username, e-mail, emailVerified: false and a createdAt of 15:14:58 UTC.

The web page right after signup: the green status bar reads signed up in 597 ms, and the Logged in panel shows the user JSON with emailVerified false and the three buttons Refresh, Resend verification e-mail and Log out

Then we logged out and tried the same username with a wrong password. The backend answers 101 Invalid username/password. for a wrong password and for an unknown username alike, which is the right behaviour: the login form must not tell an attacker which half was wrong.

The web page after a failed login: the red status bar reads 101 Invalid username/password. above the Log in form with the username still filled in

How does the same flow look in Flutter?

Identical, in Dart, with package:http and no SDK. The whole API class is 58 lines. The _call method is the api helper from the web page; the six methods below it are the six calls:

// Stack: Flutter 3.x / Dart 3.x, package:http | File: lib/auth_api.dart
class AuthApi {
  static const _base = backendUrl;
  static const _headers = {
    'X-Parse-Application-Id': appId,
    'X-Parse-Client-Key': clientKey,      // a client key: identifies the app, not the user
    'X-Parse-Revocable-Session': '1',
    'Content-Type': 'application/json',
  };
  String? sessionToken;                   // kept in memory; persist it with flutter_secure_storage in a real app

  Future<Map<String, dynamic>> _call(String method, String path, {Map<String, dynamic>? body}) async {
    final headers = {..._headers, if (sessionToken != null) 'X-Parse-Session-Token': sessionToken!};
    final uri = Uri.parse('$_base$path');
    final r = switch (method) {
      'GET' => await http.get(uri, headers: headers),
      'POST' => await http.post(uri, headers: headers, body: jsonEncode(body ?? {})),
      _ => throw ArgumentError(method),
    };
    final data = r.body.isEmpty ? <String, dynamic>{} : jsonDecode(r.body) as Map<String, dynamic>;
    if (r.statusCode >= 400) throw AuthError(r.statusCode, data['code'] as int?, data['error']?.toString() ?? r.reasonPhrase ?? '');
    return data;
  }

  Future<Map<String, dynamic>> signUp(String username, String email, String password) async {
    final u = await _call('POST', '/users', body: {'username': username, 'email': email, 'password': password});
    sessionToken = u['sessionToken'] as String;
    return u;
  }

  Future<Map<String, dynamic>> logIn(String username, String password) async {
    final u = await _call('POST', '/login', body: {'username': username, 'password': password});
    sessionToken = u['sessionToken'] as String;
    return u;
  }

  Future<Map<String, dynamic>> me() => _call('GET', '/users/me');
  Future<void> requestPasswordReset(String email) => _call('POST', '/requestPasswordReset', body: {'email': email});
  Future<void> requestVerificationEmail(String email) => _call('POST', '/verificationEmailRequest', body: {'email': email});

  Future<void> logOut() async {
    try { await _call('POST', '/logout'); } finally { sessionToken = null; }   // revoked server-side, then forgotten
  }
}

The repo’s test/auth_api_test.dart is not a unit test with mocks. It signs up a fresh user against the real backend, checks the session, logs out, logs back in and asserts that a wrong password throws an AuthError with code 101. flutter test passed on September 17, 2026, which is the only proof that counts.

The Flutter app's single screen in a phone-sized Chrome window: username, e-mail and password fields with three buttons, Create account, Log in and Send reset e-mail

One difference to be honest about: the web page keeps the token in sessionStorage; the Flutter class keeps it in memory. On a phone you want it to survive a restart, and the place for that is the platform keychain (flutter_secure_storage), not shared preferences.

What arrives when a user asks for a password reset?

An e-mail, one second later. We requested a reset for the user ana at 14:57:37 UTC through POST /requestPasswordReset and the message reached the inbox at 14:57:38, from [email protected], with the subject Password Reset Request for user-auth-starter. The body is plain text with one link.

The link points at the backend, which redirects (302) to a hosted page under /apps/choose_password: a title with your app’s name, the username, two password fields and a Change Password button. The token is in a hidden field. You did not write this page, and on the free plan you cannot restyle it, but it works.

The hosted password-reset page, titled Reset Your Password for user-auth-starter, with New Password for ana, two password fields, a Change Password button and the Powered by Back4App logo

Two details worth copying into your own form. POST /requestPasswordReset returns 200 {} whether or not the e-mail exists, so your UI should say if that address has an account, a reset e-mail is on its way, exactly like the repo does. And the reset link logs nobody in: after changing the password the user goes back to your login form, with sessions untouched.

Why did no verification e-mail arrive?

Because verification is off, and the API does not say so. We signed up with a real disposable inbox, then called POST /verificationEmailRequest with that address. The answer was 200 {}. Nothing arrived, then or later. The reset e-mail to the same inbox took one second, so delivery was not the problem.

The switch is in the dashboard under Notifications → Email → Verification, and on a free account it is behind a second gate: a banner reading Validate your card — In order to enable this feature, you must validate your card. Both toggles below it, Enable Verification and Prevent login with unverified email, are greyed out until that is done. No charge is stated on the page.

The Notifications → Email → Verification page: a Validate your card banner with a Validate Card button, and the disabled toggles Enable Verification and Prevent login with unverified email

So the honest state of a fresh free backend is: password reset works, verification does not, and emailVerified stays false on every user. The repo’s signup message says check your inbox for the verification e-mail; change that line if you ship before validating the card.

Once verification is on, the same POST /verificationEmailRequest call resends the e-mail and GET /users/me flips emailVerified to true after the click.

Where do password rules go when the settings page is read-only?

Into a hook. The dashboard has a full Password Policy section under App Settings → Advanced Options: reset token validity, a validator regular expression, a custom error message, do not allow username in password, maximum password age, password history, and an Account Lockout section with threshold and duration.

On the free plan every field is disabled behind a banner: Please upgrade your plan to change custom parse options.

The Advanced Options page in the dashboard, Password Policy section: Password Validator Pattern, Validation Error Message, Do Not Allow Username, Max Password Age and Max Password History, all greyed out on the free plan

That leaves Cloud Code, which runs on every plan. A beforeSave on the _User class runs for every signup and every profile update, whichever client sent it, so a rule written once holds for the web page, the Flutter app and a curl command alike.

Ours requires an e-mail, lower-cases it, and rejects usernames shorter than three characters. A second function, whoami, shows how a Cloud Function sees the caller:

// Stack: Node.js 22.x | Parse Server 8.x | File: cloud/main.js
// Rules about accounts live in the backend, so the web page, the Flutter app and a curl command all obey them.
Parse.Cloud.beforeSave(Parse.User, (request) => {
  const user = request.object;
  const email = (user.get("email") ?? "").trim().toLowerCase();
  if (!email) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "an e-mail address is required.");
  user.set("email", email);
  const username = (user.get("username") ?? "").trim();
  if (username.length < 3) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "username needs at least 3 characters.");
  user.set("username", username);
});

Parse.Cloud.define("whoami", async (request) => {
  if (!request.user) throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, "log in first.");
  return { id: request.user.id, username: request.user.get("username"), verified: request.user.get("emailVerified") === true };
});

A password-length or complexity rule goes in the same hook: request.object.get("password") is readable in beforeSave on a new user, before it is hashed, and never afterwards. A lockout counter is a few more lines with a failedLogins field, reset in an afterLogin trigger.

The Cloud Code editor with cloud/main.js open in the user-auth-starter backend: the beforeSave hook on Parse.User and the whoami Cloud Function, with the Deploy button top right

What does the backend show after the signups?

Three screens, and every row on them was written by the web page, the Flutter test or auth-check.sh. We defined no schema by hand.

Overview: where the keys come from. The App ID and, under the Keys dropdown, the JavaScript key for the web page and the Client key for Flutter. These are client keys: they say which app is calling, not who. Shipping them in a page is expected. The Master key in the same dropdown is the one that must never leave a server.

The backend Overview for user-auth-starter: the App ID, the Keys dropdown set to Javascript Key with its value blurred, and on the right Parse Server 7.5.2, MongoDB 3.6 and the API URL

Database: the _User class. Seven rows after our tests: web97694 from the web page, fl333903 from the Flutter test, user16949 from curl, ana with the disposable inbox that received the reset e-mail, ab and carla from our hook checks, and diana. The password column reads (hidden) for every one of them, to us as well; emailVerified is undefined because verification is off.

The Database Browser showing the _User class with seven objects: username, createdAt, a password column that reads hidden on every row, and the e-mail column

The _Session class next to it had one row per login we made, each with its token, its user and an expiresAt one year out, which is the default session length shown, read-only, under Advanced Options. Logging out deletes the row for that token and no other. That is the finding from the next section.

Which error codes will your login form see?

The backend answers with an HTTP status and a numeric code in the body. The form should branch on the code; the status is mostly 400 or 404 and tells you less. These are the ones we hit on September 17, 2026:

What you didHTTPcodeerror
Wrong password, or unknown username404101Invalid username/password.
Signup with a username that exists400202Account already exists for this username.
Signup with an e-mail that exists400203Account already exists for this email address.
Any call with a revoked or made-up token400209Invalid session token
Signup the hook rejected (ab)400142username needs at least 3 characters.
whoami without a token400209log in first.

And the one we did not expect. The curl script signs up (token A), logs in (token B), logs out with B, then tries GET /users/me with both. B gets 209. A still gets 200. Logout revokes the calling session, not the user. If “log out everywhere” is a feature you need, a Cloud Function with the master key can delete every _Session row for the user; the client key cannot.

When should you still write your own auth server?

When the identity is not yours to keep. If your users already exist in a corporate directory, the job is federation (SAML, OIDC), and a users class is the wrong shape. If you need the password policy, lockout and custom e-mail pages today, they cost a paid plan here, and it is fair to price that against a week of your own code.

If you need social login, the backend has it under App Settings → Social Auth, but that is a separate post. And if you have a compliance rule that says password hashes must sit in a database you administer, none of this applies.

For everything else, the six calls above are the auth server, and the part you own is 17 lines of rules.

What would you add to the auth starter next?

  • A real token store on mobile. Swap the in-memory sessionToken for flutter_secure_storage and call me() on app start, so a revoked session is caught before the first screen renders.
  • Lockout in the hook. Count failed logins in an afterLogin/beforeLogin pair and refuse after five in ten minutes; on the free plan that is the only place it can live.
  • Log out everywhere. A Cloud Function that deletes every _Session row for request.user with the master key. Twelve lines.
  • Verification, once the card is validated. Turn on Prevent login with unverified email, watch POST /login start refusing unverified users, and handle that error code in the form.
  • Lock down what the users can reach. A logged-in user is still just a session token. Which rows it may read and write is the next post: Your Frontend Talks Straight to the Database. Here Is How to Lock It Down.

Tagsauthenticationuserssessionspassword-resetflutterjavascriptrest-apiparse-server

Frequently asked questions

Can I add login and signup to an app without running my own authentication server?

Yes. A managed backend with a users class does it over REST: POST /users creates the account and returns a session token, POST /login returns a new token, GET /users/me validates it, POST /logout revokes it. We measured signup at 597 ms and login at 245 ms from a browser on September 17, 2026, against a Back4app backend, with no server code of our own except a 17-line validation hook.

How does the password-reset e-mail work when I have no mail server?

You call POST /requestPasswordReset with the user's e-mail. The backend sends the message from [email protected] with a one-time link; the link opens a hosted page where the user types a new password. In our test the e-mail arrived one second after the request. You do not write the e-mail, the token, or the page.

Why does POST /verificationEmailRequest return 200 but send nothing?

Verification e-mails are switched off by default. On Back4app you turn them on under Notifications → Email → Verification, and that page is gated behind validating a card on the account (no charge is stated). Until then the endpoint accepts the request and sends no e-mail. Password-reset e-mails have no such gate.

Does logging out invalidate every session of that user?

No. Each login creates its own session row, and POST /logout revokes only the token that made the call. In our measurement the token from signup still returned 200 on GET /users/me after we logged out the token from a later login. To end every session, delete the user's rows in the _Session class with the master key from Cloud Code.

Where do password rules like minimum length go?

The dashboard has a Password Policy section under App Settings → Advanced Options (validator pattern, max age, history, account lockout), but on the free plan it is read-only. Rules therefore go into a beforeSave hook on the _User class in Cloud Code, which runs for every signup regardless of which client sent it.

Further reading

Reviewed & verified by Back4app Engineering, Editorial review at Back4app

Tested Sep 17, 2026 — Backend: Back4app, free plan, USA East · Web: one HTML page + vanilla JavaScript · Mobile: Flutter 3 (web build, Chrome) · Terminal: curl on macOS.

More from Back4app Engineering

Backend · tutorial

Your Frontend Talks Straight to the Database. Here Is How to Lock It Down [Full Repo]

Backend · tutorial

Build and Deploy a Node.js REST API in 15 Minutes — Without Owning a Server [Full Code Inside]