This guide describes how a Flutter app should integrate with the Papyrus server authentication that already exists in this backend.
It covers:
- Android
- iOS
- macOS
- Windows
- Linux
- Flutter web
Papyrus is the authentication authority. The Flutter app should not talk to Google directly as its main auth API. Instead:
- email/password uses Papyrus auth endpoints directly
- Google login uses a Papyrus-owned browser OAuth flow
- PowerSync uses Papyrus-issued PowerSync tokens after Papyrus authentication succeeds
Papyrus is designed for an offline-first client.
- The app can remain unauthenticated while the user is using only local features.
- The app authenticates only when the user enables cloud-backed features such as sync.
- The server owns all account state, sessions, refresh-token rotation, Google identity linking, and PowerSync token minting.
- Google is only an upstream identity provider. The Flutter app should never send Google access tokens or ID tokens to Papyrus APIs unless the backend contract explicitly changes in the future.
Use these packages as the baseline:
diofor API calls and interceptorsflutter_secure_storagefor storing the Papyrus refresh token on Android, iOS, macOS, Windows, and Linuxflutter_web_auth_2for browser-based Google OAuth login and callback handlingapp_linksonly if you need deeper custom scheme or universal-link handling thanflutter_web_auth_2already provides
If your app already standardizes on http instead of dio, the HTTP contract stays the same. The main reason to prefer dio here is interceptor support for bearer-token attachment and one-time refresh retry.
The Flutter app should integrate with these server endpoints:
POST /v1/auth/registerPOST /v1/auth/loginPOST /v1/auth/refreshPOST /v1/auth/logoutPOST /v1/auth/logout-allGET /v1/auth/oauth/google/startPOST /v1/auth/exchange-codePOST /v1/auth/link/google/startPOST /v1/auth/link/google/completePOST /v1/auth/powersync-tokenGET /v1/users/me
Related endpoints that are usually needed in a real client flow:
POST /v1/auth/resend-verificationPOST /v1/auth/verify-emailPOST /v1/auth/forgot-passwordPOST /v1/auth/reset-passwordPOST /v1/users/me/change-password
Papyrus returns:
access_token: short-lived bearer token for normal API requestsrefresh_token: long-lived token used to get a new access tokenexpires_in: access-token lifetime in secondsuser: authenticated user profile
Recommended storage model:
- Keep
access_tokenin memory only. - Store
refresh_tokeninflutter_secure_storageon Android, iOS, macOS, Windows, and Linux. - On Flutter web, do not assume secure local storage is equivalent to native secure storage. Prefer in-memory access tokens and carefully managed refresh behavior. If you persist a refresh token in web storage, treat that as a weaker security posture and scope it accordingly.
- Every successful refresh rotates the refresh token. The client must overwrite the previously stored refresh token every time
POST /v1/auth/refreshsucceeds.
POST /v1/auth/register
{
"email": "reader@example.com",
"password": "SecureP@ss123",
"display_name": "Reader",
"client_type": "mobile",
"device_label": "pixel-9"
}Successful response:
{
"access_token": "<jwt>",
"refresh_token": "<opaque-token>",
"token_type": "Bearer",
"expires_in": 3600,
"user": {
"user_id": "11111111-1111-1111-1111-111111111111",
"email": "reader@example.com",
"display_name": "Reader",
"avatar_url": null,
"email_verified": false,
"created_at": "2026-03-28T12:00:00Z",
"last_login_at": "2026-03-28T12:00:00Z"
}
}POST /v1/auth/login
{
"email": "reader@example.com",
"password": "SecureP@ss123",
"client_type": "desktop",
"device_label": "macbook-air"
}Response shape is the same as register.
POST /v1/auth/refresh
{
"refresh_token": "<stored-refresh-token>"
}Response shape is also the same as register. The returned refresh_token replaces the old one.
After a successful Google browser flow, Papyrus redirects back to the app with a Papyrus one-time code. The app then calls:
POST /v1/auth/exchange-code
{
"code": "<papyrus-exchange-code>",
"client_type": "web",
"device_label": "chrome"
}Response shape is the same as register.
GET /v1/users/me
Requires:
Authorization: Bearer <access_token>Response:
{
"user_id": "11111111-1111-1111-1111-111111111111",
"email": "reader@example.com",
"display_name": "Reader",
"avatar_url": null,
"email_verified": true,
"created_at": "2026-03-28T12:00:00Z",
"last_login_at": "2026-03-28T12:15:00Z"
}POST /v1/auth/powersync-token
Requires bearer auth.
Response:
{
"token": "<powersync-jwt>",
"expires_in": 300
}Keep the client split into a few clear responsibilities.
The repository should own:
- register
- login
- refresh
- logout
- logout all
- Google login start and exchange-code completion
- Google link start and complete
- fetch current user
- fetch PowerSync token
The token store should own:
- current in-memory
accessToken - persisted
refreshToken - loading the persisted refresh token at app startup
- replacing the stored refresh token after refresh
- clearing both tokens on sign-out or unrecoverable auth failure
Use an explicit auth state model instead of only checking whether a token exists.
Recommended states:
signedOutauthenticatingsignedInrefreshingauthError
At minimum, signedIn should hold:
- current user
- current access token in memory
- current refresh token presence
- User enables cloud features and chooses sign-up.
- App calls
POST /v1/auth/register. - App keeps
access_tokenin memory. - App stores
refresh_tokenin secure storage on native/desktop. - App transitions to authenticated state and may immediately call
GET /v1/users/me.
- App calls
POST /v1/auth/login. - Store tokens the same way as register.
- Treat the returned
useras the initial authenticated profile.
The HTTP client should:
- attach
Authorization: Bearer <access_token>to protected requests - if a protected request returns
401, attempt exactly one refresh - call
POST /v1/auth/refreshwith the stored refresh token - replace both in-memory access token and stored refresh token with the returned values
- retry the original request once
- if refresh fails, clear tokens and transition to signed-out
Do not allow multiple simultaneous refresh operations. Use a single in-flight refresh guard so concurrent 401 responses wait on the same refresh result.
Use:
POST /v1/auth/logoutto invalidate the current sessionPOST /v1/auth/logout-allto invalidate all sessions
After either call:
- clear in-memory access token
- clear stored refresh token
- transition to signed-out
Papyrus uses a server-owned browser flow.
The Flutter app should not use google_sign_in as the primary integration path here. The server already owns:
- the Google client ID and secret
- the Google callback
- identity verification
- account linking rules
Use a callback URI owned by the app, for example:
papyrus://auth/callback
Recommended flow with flutter_web_auth_2:
- Build the Papyrus start URL:
GET /v1/auth/oauth/google/start?redirect_uri=papyrus://auth/callback
- Open that URL in the system browser with
flutter_web_auth_2. - Google authenticates the user.
- Papyrus receives the Google callback at
/v1/auth/oauth/google/callback. - Papyrus redirects to
papyrus://auth/callback?code=<papyrus-code>orpapyrus://auth/callback?error=<error>. - The app extracts
codefrom the callback URL. - The app calls
POST /v1/auth/exchange-code. - Papyrus returns normal auth tokens and the user profile.
Example Dart shape:
final result = await FlutterWebAuth2.authenticate(
url: '$baseUrl/v1/auth/oauth/google/start?redirect_uri=${Uri.encodeComponent('papyrus://auth/callback')}',
callbackUrlScheme: 'papyrus',
);
final callbackUri = Uri.parse(result);
final code = callbackUri.queryParameters['code'];
final error = callbackUri.queryParameters['error'];If error is present, treat the login as failed and do not call /v1/auth/exchange-code.
For Flutter web, the callback should be owned by the web app, for example:
https://app.example.com/auth/callback
The flow is the same, except the app callback is an HTTPS route in the web app instead of a custom scheme.
- User clicks “Continue with Google”.
- App navigates the browser to:
GET /v1/auth/oauth/google/start?redirect_uri=https://app.example.com/auth/callback
- After Google auth, Papyrus redirects to:
https://app.example.com/auth/callback?code=<papyrus-code>
- The Flutter web route reads the
code. - The app calls
POST /v1/auth/exchange-code. - The app stores tokens according to the web storage policy chosen by the app.
Important distinction:
- the Flutter app callback URI is the
redirect_uriquery parameter passed to Papyrus - the Google redirect URI configured in Google Cloud must point to the Papyrus backend callback
- these are different URLs and should not be confused
The backend callback is always the Papyrus server callback:
https://<server-host>/v1/auth/oauth/google/callback
That server callback must match:
PUBLIC_BASE_URL- the Google Cloud OAuth redirect URI configuration
The Flutter app callback must not be registered as the Google redirect URI. Papyrus redirects to the app callback only after Papyrus has already completed the Google exchange.
Linking Google to an existing Papyrus account requires an already authenticated Papyrus session.
Flow:
- App is already signed in with Papyrus.
- App calls
POST /v1/auth/link/google/startwith:
{
"redirect_uri": "papyrus://auth/callback"
}- Papyrus returns:
{
"authorization_url": "https://accounts.google.com/..."
}- App opens
authorization_urlin the browser. - After browser completion, Papyrus redirects back to the app with a one-time Papyrus
code. - App calls
POST /v1/auth/link/google/completewith that code.
{
"code": "<papyrus-link-code>"
}Important rule:
- Papyrus does not auto-link by email
If a Google account has the same email as an existing Papyrus account but is not linked yet, the user must first authenticate to Papyrus and then explicitly link Google.
After Papyrus authentication succeeds, the app should fetch a PowerSync token from Papyrus:
POST /v1/auth/powersync-token
Use the Papyrus access token to authenticate that request.
The PowerSync token is separate from the Papyrus API access token:
- Papyrus API token authenticates calls to the Papyrus backend
- PowerSync token authenticates calls to PowerSync
Do not send Google tokens directly to PowerSync.
Recommended client behavior:
- request a fresh PowerSync token on PowerSync startup
- refresh it when PowerSync needs new credentials
- keep PowerSync token handling separate from the main Papyrus refresh-token flow
The Flutter app should handle these cases explicitly.
- try refresh once
- if refresh succeeds, retry the failed request once
- if refresh fails, clear tokens and sign the user out
Papyrus validates sessions server-side. Existing access tokens can stop working immediately after:
- logout
- logout all
- password change
- password reset
- account disablement
The client should treat 401 or 403 after those operations as expected behavior, not as a transport problem.
If the app callback contains:
?error=...
then the app should:
- surface a user-friendly auth failure
- not call
/v1/auth/exchange-code - keep the current auth state unchanged unless the login flow was replacing an existing session intentionally
Refresh tokens rotate. If the app fails to persist the newly returned refresh token, the next refresh may fail because the previously stored token is stale.
This is one of the most important client integration details in this auth design.
For local backend testing:
- API docs:
http://localhost:8080/docs - auth sandbox:
http://localhost:8080/__dev/auth-sandbox - Mailpit:
http://localhost:8025
Use the sandbox to verify:
- email/password register and login
- Google browser login
- exchange-code to tokens
- refresh rotation
/users/me- PowerSync token minting
See auth-testing.md for:
- local server setup
- Mailpit and SMTP testing
- Google Cloud setup for the backend callback
- provider smoke tests
Implement the client in this order:
- email/password register and login
- token store and refresh interceptor
/users/mebootstrap on app launch- logout and logout-all
- Google browser login with exchange-code completion
- Google account linking
- PowerSync token integration
This keeps the auth foundation simple before layering in browser and sync-specific behavior.