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:3100from the iOS Simulator. On a real iPhone, use your Mac's local IP address, for examplehttp://192.168.1.20:3100. - Plain
httpin dev: iOS blocks plainhttpby default. AddNSAppTransportSecurity→NSAllowsLocalNetworking = YEStoInfo.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.
| Code | Status | Meaning |
|---|---|---|
VALIDATION_ERROR | 400 | The request body is wrong. A bug in the app. |
UNAUTHORIZED | 401 | Token missing, expired, or no longer valid. See "When you get 401". |
RATE_LIMITED | 429 | Too many requests. Wait and retry. |
INTERNAL_ERROR | 500 | Server bug. Show "Something went wrong". |
Packages
| Package | Use |
|---|---|
sign_in_with_apple | Apple sign-in sheet and the credential state check |
flutter_secure_storage | iOS Keychain storage |
dio | HTTP client with interceptors |
crypto | SHA-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:
- Add
GIDClientID= the iOS client ID (ask the backend team; it is the server'sGOOGLE_CLIENT_ID). - Add a URL type (
CFBundleURLTypes) whose URL scheme is the reversed client ID, for examplecom.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
| Key | Value | When to delete it |
|---|---|---|
guest_secret | The guest secret | After the guest signs in with Apple, and after account deletion |
token | Our login token | On logout, deletion, or 401 |
apple_user_id | Apple's userIdentifier | On logout or deletion |
google_user_id | Google's account.id | On 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:
- If
tokenexists andapple_user_idexists, callSignInWithApple.getCredentialState(appleUserId).revokedornotFound→ sign out (deletetokenandapple_user_id), then go to step 3.authorized→ go on to step 2.
- If
tokenexists, callGET /me.200→ the user is logged in. Done.401→ deletetoken, then go to step 3.
- No token: if
apple_user_idorgoogle_user_idwas 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:
- Save
token. - Save
credential.userIdentifierasapple_user_id. - 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:
| Code | Status | What to do |
|---|---|---|
INVALID_APPLE_TOKEN | 401 | Show "Apple sign-in failed. Try again." Also returned when the nonce is wrong or older than 5 minutes. |
ACCOUNT_EXISTS | 409 | Only 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:
- Save
token. - Save
account.idasgoogle_user_id. - Delete
guest_secret.
Errors:
| Code | Status | What to do |
|---|---|---|
INVALID_GOOGLE_TOKEN | 401 | Show "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_EXISTS | 409 | Only 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_idnorgoogle_user_idstored): callPOST /auth/guestwith the stored secret, save the new token, and retry the request once. - Apple or Google user: delete
tokenand 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:
- Show the Apple sheet again with a new nonce:
appleSheet([]). - Call:
DELETE /me
Authorization: Bearer <token>
{
"identityToken": "<new identityToken>",
"authorizationCode": "<new authorizationCode>",
"nonce": "<new rawNonce>"
}
Google user:
- Show Google sign-in again:
googleSheet(). - 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:
| Code | Status | What to do |
|---|---|---|
REAUTH_REQUIRED | 403 | The 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
-
401interceptor: guests log in again silently, Apple and Google users see the sign-in screen - Fresh nonce from
GET /auth/noncebefore every Apple sheet. Hash to Apple, raw to the server. - Name sent on the first Apple sign-in
-
GIDClientIDand the reversed client ID URL scheme inInfo.plist -
GoogleSignIn.instance.initialize()once at start.authenticate()for every sign-in, then call the server right away. -
guest_secretdeleted after the guest signs in with Apple or Google -
409 ACCOUNT_EXISTShandled - Delete account with fresh proof, then clear the Keychain