Skip to main content

Auth: Flutter guide

This guide covers guest mode, Sign in with Apple, Sign in with Google, keeping the user logged in, and account deletion.

Basics for every endpoint​

  • Base URL (local dev): http://localhost:3100 from the iOS Simulator. On a real iPhone, use your Mac's local IP address, for example http://192.168.1.20:3100.
  • Plain http in dev: iOS blocks plain http by default. Add NSAppTransportSecurity → NSAllowsLocalNetworking = YES to Info.plist, in debug builds only.
  • API docs page: http://localhost:3100/api/docs (dev only). You can try every endpoint there.
  • Auth header: after sign-in, send Authorization: Bearer <token> on every request.
  • Errors: every error has the same shape:
{ "error": { "code": "REAUTH_REQUIRED", "message": "Confirm it is you before deleting the account." } }

Use code in your logic. message is for logs. Write your own text for the user.

CodeStatusMeaning
VALIDATION_ERROR400The request body is wrong. A bug in the app.
UNAUTHORIZED401Token missing, expired, or no longer valid. See "When you get 401".
RATE_LIMITED429Too many requests. Wait and retry.
INTERNAL_ERROR500Server bug. Show "Something went wrong".

Packages​

PackageUse
sign_in_with_appleApple sign-in sheet and the credential state check
flutter_secure_storageiOS Keychain storage
dioHTTP client with interceptors
cryptoSHA-256 of the nonce
google_sign_in (7.x)Google sign-in screen

In Xcode, add the Sign in with Apple capability to the Runner target.

For Google, in ios/Runner/Info.plist:

  1. Add GIDClientID = the iOS client ID (ask the backend team; it is the server's GOOGLE_CLIENT_ID).
  2. Add a URL type (CFBundleURLTypes) whose URL scheme is the reversed client ID, for example com.googleusercontent.apps.1234-abcd.

Apple requires Sign in with Apple whenever Google sign-in is offered. Show both buttons.

What to store in the Keychain​

KeyValueWhen to delete it
guest_secretThe guest secretAfter the guest signs in with Apple, and after account deletion
tokenOur login tokenOn logout, deletion, or 401
apple_user_idApple's userIdentifierOn logout or deletion
google_user_idGoogle's account.idOn logout or deletion

Do not clear the Keychain on first launch. Many apps do this. It would break the "reinstall gives back the same guest" feature.

1. App launch​

Follow these steps in order:

  1. If token exists and apple_user_id exists, call SignInWithApple.getCredentialState(appleUserId).
    • revoked or notFound → sign out (delete token and apple_user_id), then go to step 3.
    • authorized → go on to step 2.
  2. If token exists, call GET /me.
    • 200 → the user is logged in. Done.
    • 401 → delete token, then go to step 3.
  3. No token: if apple_user_id or google_user_id was there before, show the sign-in screen. Otherwise, log in as a guest (section 2).

Google has no credential state check. A Google user stays signed in until our token stops working.

2. Guest login​

Make the secret once and keep it forever:

import 'dart:convert';
import 'dart:math';

String newGuestSecret() {
final rand = Random.secure();
final bytes = List<int>.generate(32, (_) => rand.nextInt(256));
return base64Url.encode(bytes).replaceAll('=', ''); // 43 characters
}

Future<String> guestSecret() async {
var secret = await storage.read(key: 'guest_secret');
if (secret == null) {
secret = newGuestSecret();
await storage.write(key: 'guest_secret', value: secret);
}
return secret;
}

Call:

POST /auth/guest
{ "secret": "<guest secret>" }

Response 200:

{
"token": "eyJhbGciOi...",
"user": {
"id": "4ee690f1-853c-4ec0-bbc0-6371bd8f1da0",
"isGuest": true,
"email": null,
"name": null,
"plan": "FREE",
"planExpiresAt": null,
"unitSystem": "US"
}
}

Save token. Calling this again with the same secret returns the same user. Use this whenever a guest's token stops working.

3. Sign in with Apple​

Use this for a new user, a returning user, and a guest who wants to keep their data. The call is the same for all three.

First get a nonce: a random value that ties this sign-in to our server. It is valid for 5 minutes, so get a fresh one every time you show the Apple sheet.

GET /auth/nonce

Response 200: { "nonce": "1793791048623.uvlTjNnv...e1bJa3Hl..." }

Give Apple the SHA-256 hash of the nonce. Send the server the raw nonce.

import 'dart:convert';
import 'package:crypto/crypto.dart';

Future<(AuthorizationCredentialAppleID, String)> appleSheet(List<AppleIDAuthorizationScopes> scopes) async {
final res = await dio.get('/auth/nonce');
final rawNonce = res.data['nonce'] as String;
final credential = await SignInWithApple.getAppleIDCredential(
scopes: scopes,
nonce: sha256.convert(utf8.encode(rawNonce)).toString(),
);
return (credential, rawNonce);
}

final (credential, rawNonce) = await appleSheet(
[AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName],
);

final fullName = [credential.givenName, credential.familyName]
.whereType<String>()
.join(' ');

Call (if the user is a guest now, send their guest token in the Authorization header):

POST /auth/apple
Authorization: Bearer <guest token> (only if the user is a guest)
{
"identityToken": "<credential.identityToken>",
"authorizationCode": "<credential.authorizationCode>",
"nonce": "<rawNonce>",
"fullName": "Sam Lee" (send null if empty)
}

Response 200: same shape as guest login. user.isGuest is false.

Then:

  1. Save token.
  2. Save credential.userIdentifier as apple_user_id.
  3. Delete guest_secret. The server has already turned the guest into an Apple account.

The name comes only once. Apple gives givenName and familyName only on the very first sign-in. Always send them when present. If the request fails, retry with the same values. Do not show the sheet again, or the name is lost.

Errors:

CodeStatusWhat to do
INVALID_APPLE_TOKEN401Show "Apple sign-in failed. Try again." Also returned when the nonce is wrong or older than 5 minutes.
ACCOUNT_EXISTS409Only for guests. This Apple ID already has an account. Ask: "You already have an account. Switch to it? Recipes saved as a guest stay on this phone's guest account." If yes: call POST /auth/apple again with the same body, without the guest token. This works within 5 minutes. Later, show the Apple sheet again with a new nonce. Save the new token. Keep guest_secret.

4. Sign in with Google​

Use this for a new user, a returning user, and a guest who wants to keep their data. Same rules as Apple. No nonce.

Call initialize once, at app start:

import 'package:google_sign_in/google_sign_in.dart';

await GoogleSignIn.instance.initialize();

Then, when the user taps the Google button:

Future<GoogleSignInAccount> googleSheet() async {
// Always the full sign-in screen. Do not use attemptLightweightAuthentication():
// it can return an old ID token, and the server only takes tokens less than 5 minutes old.
return GoogleSignIn.instance.authenticate();
}

final account = await googleSheet();
final idToken = account.authentication.idToken!;

Call right away (if the user is a guest now, send their guest token in the Authorization header):

POST /auth/google
Authorization: Bearer <guest token> (only if the user is a guest)
{ "idToken": "<idToken>" }

Response 200: same shape as guest login. user.isGuest is false. The server takes the name and email from the token.

Then:

  1. Save token.
  2. Save account.id as google_user_id.
  3. Delete guest_secret.

Errors:

CodeStatusWhat to do
INVALID_GOOGLE_TOKEN401Show "Google sign-in failed. Try again." Also returned when the ID token is more than 5 minutes old. Call GoogleSignIn.instance.signOut() and let the user try again.
ACCOUNT_EXISTS409Only for guests. This Google account already has an account. Ask: "You already have an account. Switch to it?" If yes: call POST /auth/google again with the same idToken, without the guest token. This works within 5 minutes of sign-in. Later, show Google sign-in again. Save the new token. Keep guest_secret.

Apple and Google are separate accounts. If a user signs in with Apple once and with Google later, they get two accounts with different data. Show which one they used on the profile screen.

5. Get the current user​

GET /me
Authorization: Bearer <token>

Response 200: the user object from above. Use isGuest to decide whether to show "Sign in with Apple to keep your recipes safe".

6. When you get 401​

Add a dio interceptor for any 401:

  • Guest (neither apple_user_id nor google_user_id stored): call POST /auth/guest with the stored secret, save the new token, and retry the request once.
  • Apple or Google user: delete token and show the sign-in screen.

The token lasts 1 year. A 401 usually means the account was deleted, the user turned off Sign in with Apple in iOS Settings, or the token expired.

7. Delete account​

Apple requires a "Delete account" button in the app. The server asks for fresh proof first.

Apple user:

  1. Show the Apple sheet again with a new nonce: appleSheet([]).
  2. Call:
DELETE /me
Authorization: Bearer <token>
{
"identityToken": "<new identityToken>",
"authorizationCode": "<new authorizationCode>",
"nonce": "<new rawNonce>"
}

Google user:

  1. Show Google sign-in again: googleSheet().
  2. Call right away:
DELETE /me
Authorization: Bearer <token>
{ "identityToken": "<new Google idToken>" }

Guest:

DELETE /me
Authorization: Bearer <token>
{ "secret": "<guest secret>" }

With dio: dio.delete('/me', data: {...}).

Response 204 (no body). Then delete token, apple_user_id, google_user_id, and guest_secret from the Keychain. For Google users, also call GoogleSignIn.instance.signOut(). Then go to the first screen.

Errors:

CodeStatusWhat to do
REAUTH_REQUIRED403The proof was wrong: a different Apple ID or Google account, a Google token more than 5 minutes old, or a wrong secret. Nothing was deleted. Show "Could not confirm your account."

Checklist​

  • Sign in with Apple capability in Xcode
  • Guest secret made once, kept in the Keychain, never cleared on first launch
  • Credential state check on launch for Apple users
  • 401 interceptor: guests log in again silently, Apple and Google users see the sign-in screen
  • Fresh nonce from GET /auth/nonce before every Apple sheet. Hash to Apple, raw to the server.
  • Name sent on the first Apple sign-in
  • GIDClientID and the reversed client ID URL scheme in Info.plist
  • GoogleSignIn.instance.initialize() once at start. authenticate() for every sign-in, then call the server right away.
  • guest_secret deleted after the guest signs in with Apple or Google
  • 409 ACCOUNT_EXISTS handled
  • Delete account with fresh proof, then clear the Keychain
⤓ Download .md