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
- Your client generates a stable, opaque
device_idon first run and persists it (localStorage,AsyncStorage, or equivalent). - Client calls
POST /v1/auth/users/anonymouswith thedevice_id. The API returns an access token + refresh token, exactly like a regular sign-in. - The same
device_idis idempotent within an (application, environment): re-calling returns the same user but issues a fresh token pair. Prior sessions remain valid until natural expiry. - When the user is ready to sign up for real, your client calls
POST /v1/auth/users/convertwith their anonymous token + new email/password. Theend_user.idis 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.
| Header | Value | Required |
|---|---|---|
X-Application-Key | Your app's public key (za_pub_xxxx) | Always |
Content-Type | application/json | Yes |
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"
}'
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
| Field | Type | Required | Notes |
|---|---|---|---|
device_id | string (8–256 chars) | Yes | Stable per-device identifier. Generate once with a CSPRNG and persist client-side. |
name | string | No | Display name. If omitted, the API generates a friendly one like "Brave Panda" or "Swift Eagle". Pass an empty string to leave it null. |
metadata | object | No | Arbitrary JSON you can attach to the user record. |
Response codes
| Status | Meaning |
|---|---|
201 | New anonymous user created. |
200 | Existing anonymous user for this device_id; fresh tokens issued. |
400 | Missing device_id, invalid length, or the application key doesn't resolve an environment. Anonymous sign-in requires an environment-scoped key. |
403 | Test environment user limit exceeded. |
429 | Rate 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"
}'
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
| Field | Type | Required | Notes |
|---|---|---|---|
method | "password" | Yes | |
email | string | Yes | Must not already be in use within this environment. |
password | string | Yes | Must satisfy the application's password policy. |
name | string | No | Overwrites 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:
- Your client calls
GET /v1/auth/oauth/authorizeto get an authorization URL, redirects the user there. - The provider redirects back with
codeandstatequery parameters. - Your client POSTs
code+stateto/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..."
}'
| Field | Type | Required | Notes |
|---|---|---|---|
method | "oauth" | Yes | |
code | string | Yes | Authorization code returned by the OAuth provider. |
state | string | Yes | Opaque state token previously issued by GET /v1/auth/oauth/authorize. |
apple_user | object | No | Sign 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
| Status | Meaning |
|---|---|
200 | Conversion successful. Response includes a fresh token pair. |
400 | Validation error or password does not meet requirements. |
401 | Missing or invalid anonymous bearer token. |
409 | User not found, already converted, or email already in use. |
What's preserved across conversion
| Field | Behavior |
|---|---|
id | Preserved. All foreign keys in your customer-domain tables continue to work. |
is_anonymous | Flipped to false. |
anonymous_device_id | Preserved. Lets your client still detect "this device has scores from this user." |
email / password_hash | Set from the conversion request. |
name | Overwritten if provided in the convert call; otherwise unchanged. |
| Sessions / refresh tokens | All revoked. The anonymous token used to make the convert call no longer authenticates. Use the new token pair returned by the convert response. |
metadata | Preserved. |
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_idintestandliveenvironments 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.