Skip to main content

Anonymous Sign-In

Anonymous sign-in issues a usable end-user identity to a device without requiring email, password, OAuth, or any other credential. The user can later upgrade to a full account in place — the end_user.id is preserved, so anything you've foreign-keyed to that id stays valid.

This pattern is the right fit for:

  • Trial flows — "try the app before you create an account"
  • Games, quizzes, leaderboards — submit a score now, claim it under a real name later
  • Consumer apps where account creation friction kills onboarding

How it works

  1. Your client generates a stable, opaque device_id on first run and persists it (localStorage, AsyncStorage, or equivalent).
  2. Client calls POST /v1/auth/users/anonymous with the device_id. The API returns an access token + refresh token, exactly like a regular sign-in.
  3. The same device_id is idempotent within an (application, environment): re-calling returns the same user but issues a fresh token pair. Prior sessions remain valid until natural expiry.
  4. When the user is ready to sign up for real, your client calls POST /v1/auth/users/convert with their anonymous token + new email/password. The end_user.id is preserved; all prior sessions (including the anonymous one) are revoked; a fresh token pair is returned.

Authentication

Anonymous sign-in is public-key only — no secret key required. It's safe to call from a browser, mobile app, or any client.

HeaderValueRequired
X-Application-KeyYour app's public key (za_pub_xxxx)Always
Content-Typeapplication/jsonYes

Conversion requires the anonymous user's bearer token.

Billing

Anonymous users do not count toward your MAU quota until their first authenticated request after sign-in. Refresh-token calls do not count as "first activity." Conversion to a full account always counts.

This means bot or scraper sign-ups that never use the issued token cost you nothing. The first time the anonymous user does something real — fetches their profile, submits a score, calls any endpoint other than /auth/anonymous or /auth/refresh — they flip to MAU.

Sign in anonymously

curl -X POST https://api.zyphr.dev/v1/auth/users/anonymous \
-H "X-Application-Key: za_pub_xxxx" \
-H "Content-Type: application/json" \
-d '{
"device_id": "dev_abc123-stable-uuid"
}'
Node.js (SDK)
import { Zyphr } from '@zyphr-dev/node-sdk';

const zyphr = new Zyphr({
apiKey: process.env.ZYPHR_API_KEY,
applicationKey: process.env.ZYPHR_APP_PUBLIC_KEY,
});

// Generate device_id once and persist it client-side
const deviceId = 'dev_' + crypto.randomUUID();

const { data } = await zyphr.auth.registration.signInAnonymously({
deviceId,
});

console.log(data.user);
// {
// id: 'a1b2c3...',
// is_anonymous: true,
// name: 'Brave Panda', // auto-generated; pass `name` to override
// anonymous_device_id: 'dev_abc123...',
// created_at: '2026-05-13T...',
// ...
// }

console.log(data.tokens);
// {
// access_token: 'eyJhbGciOi...',
// refresh_token: 'eyJhbGciOi...',
// expires_in: 3600,
// token_type: 'Bearer'
// }

Request body

FieldTypeRequiredNotes
device_idstring (8–256 chars)YesStable per-device identifier. Generate once with a CSPRNG and persist client-side.
namestringNoDisplay name. If omitted, the API generates a friendly one like "Brave Panda" or "Swift Eagle". Pass an empty string to leave it null.
metadataobjectNoArbitrary JSON you can attach to the user record.

Response codes

StatusMeaning
201New anonymous user created.
200Existing anonymous user for this device_id; fresh tokens issued.
400Missing device_id, invalid length, or the application key doesn't resolve an environment. Anonymous sign-in requires an environment-scoped key.
403Test environment user limit exceeded.
429Rate limit exceeded. Anonymous sign-in uses the same plan-tier rate limit as registration.

Convert to a full account

When the user is ready to sign up for real:

curl -X POST https://api.zyphr.dev/v1/auth/users/convert \
-H "X-Application-Key: za_pub_xxxx" \
-H "Authorization: Bearer <anonymous_access_token>" \
-H "Content-Type: application/json" \
-d '{
"method": "password",
"email": "user@example.com",
"password": "SecureP@ss123!",
"name": "Real User"
}'
Node.js (SDK)
const converted = await zyphr.auth.registration.convertAnonymousUser(
{
method: 'password',
email: 'user@example.com',
password: 'SecureP@ss123!',
name: 'Real User',
},
zyphr.asEndUser(anonymousAccessToken)
);

console.log(converted.data.user.id === anonUser.id); // true — id is preserved
console.log(converted.data.user.is_anonymous); // false
console.log(converted.data.user.email); // 'user@example.com'

Request body — password method

FieldTypeRequiredNotes
method"password"Yes
emailstringYesMust not already be in use within this environment.
passwordstringYesMust satisfy the application's password policy.
namestringNoOverwrites the user's existing display name if provided.

Request body — OAuth method

Use the OAuth method when the user wants to upgrade their anonymous identity using a Google / Apple / GitHub / etc. account instead of a password. The flow is:

  1. Your client calls GET /v1/auth/oauth/authorize to get an authorization URL, redirects the user there.
  2. The provider redirects back with code and state query parameters.
  3. Your client POSTs code + state to /v1/auth/users/convert (with the anonymous bearer token) instead of /v1/auth/oauth/callback.
curl -X POST https://api.zyphr.dev/v1/auth/users/convert \
-H "X-Application-Key: za_pub_xxxx" \
-H "Authorization: Bearer <anonymous_access_token>" \
-H "Content-Type: application/json" \
-d '{
"method": "oauth",
"code": "4/0AY0e-g...",
"state": "abc123..."
}'
FieldTypeRequiredNotes
method"oauth"Yes
codestringYesAuthorization code returned by the OAuth provider.
statestringYesOpaque state token previously issued by GET /v1/auth/oauth/authorize.
apple_userobjectNoSign in with Apple only. First-call name payload from the provider, since Apple omits the name on subsequent sign-ins. Shape: { name: { firstName, lastName } }.

Collision rules for OAuth conversion (returned as 409):

  • The OAuth provider identity (e.g. a specific Google account) is already linked to a different end user.
  • The OAuth-resolved email is already in use by a different end user in the same environment.

Response codes

StatusMeaning
200Conversion successful. Response includes a fresh token pair.
400Validation error or password does not meet requirements.
401Missing or invalid anonymous bearer token.
409User not found, already converted, or email already in use.

What's preserved across conversion

FieldBehavior
idPreserved. All foreign keys in your customer-domain tables continue to work.
is_anonymousFlipped to false.
anonymous_device_idPreserved. Lets your client still detect "this device has scores from this user."
email / password_hashSet from the conversion request.
nameOverwritten if provided in the convert call; otherwise unchanged.
Sessions / refresh tokensAll revoked. The anonymous token used to make the convert call no longer authenticates. Use the new token pair returned by the convert response.
metadataPreserved.

Common patterns

Browser app: persist the device_id

function getOrCreateDeviceId() {
let id = localStorage.getItem('zyphr_device_id');
if (!id) {
id = 'dev_' + crypto.randomUUID();
localStorage.setItem('zyphr_device_id', id);
}
return id;
}

React Native: AsyncStorage-backed device_id

import AsyncStorage from '@react-native-async-storage/async-storage';
import { randomUUID } from 'expo-crypto';

async function getOrCreateDeviceId() {
let id = await AsyncStorage.getItem('zyphr_device_id');
if (!id) {
id = 'dev_' + randomUUID();
await AsyncStorage.setItem('zyphr_device_id', id);
}
return id;
}

After conversion: swap the stored token

The convert response invalidates the anonymous token. Your client must use the fresh token from converted.data.tokens going forward.

// Before conversion
let accessToken = anonymousResponse.data.tokens.access_token;

// After conversion
accessToken = converted.data.tokens.access_token; // anonymous token now 401s

Limitations

  • Devices can have only one anonymous identity per (application, environment). Switching devices mid-anonymous session is not supported in v1 — the user must convert first.
  • No cross-environment device sharing. The same device_id in test and live environments creates two separate anonymous users (this is intentional — environment isolation).
  • Anonymous users cannot be migrated to another user's account. The conversion target must be a brand-new email / OAuth identity; merging into an existing real user is not supported.