Sign-in and tokens

Every sign-in method returns the same two tokens and the user:

{
  "data": {
    "access_token": "eyJhbGciOiJSUzI1NiIs…",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "rt_…",
    "user": {
      "uid": "01j9x6k3m4q5r6s7t8v9w0x1y2",
      "email": "jane@example.com",
      "email_verified": false,
      "first_name": "Jane",
      "last_name": null,
      "photo_url": null,
      "created_at": "2026-09-27T10:15:00.000000Z"
    }
  }
}
  • The access token is a short-lived JWT (the app's setting, 5 minutes to 24 hours; expires_in seconds). Send it as Authorization: Bearer <access_token>. Keep it in memory.
  • The refresh token gets new tokens when the access token expires. Keep it in the platform's secure storage (Keychain, Android Keystore). It expires after 90 days without use.

Users belong to one app: the same email in two apps is two separate accounts.

Email and password

POST /auth/sign-up creates the account (201) and signs in; POST /auth/sign-in signs in.

curl https://auth.wizgu.com/api/v2/auth/sign-up \
  -H "X-App-Key: $APP_KEY" -H "Accept: application/json" -H "Content-Type: application/json" \
  -d '{"email": "jane@example.com", "password": "correct horse battery", "first_name": "Jane"}'
Code Meaning
auth/email-already-in-use (409) Sign-up with an email that has an account in this app
auth/weak-password (422) Fewer than 8 characters; see fields
auth/invalid-credential (401) Wrong email or password (the same answer for both, on purpose)
auth/user-disabled (403) The account was disabled

Keeping the user signed in

The helper below signs in, stores the refresh token, adds the access token to each call, and refreshes once when the access token has expired (auth/token-expired) or isn't known yet after a restart (auth/invalid-token). Only one refresh runs at a time: each refresh token works once, so two parallel refreshes with the same token would end the session. It builds on the call helper from the overview.

// Flutter: package:flutter_secure_storage keeps the refresh token in the Keychain / Keystore.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class Session {
  final _storage = const FlutterSecureStorage();
  String? _accessToken;
  Future<void>? _refreshing;

  Future<Map<String, dynamic>> signIn(String email, String password) async {
    final json = await call('POST', '/auth/sign-in', body: {'email': email, 'password': password});
    await _keep(json['data']);
    return json['data']['user'];
  }

  /// A call as the signed-in user, refreshing the tokens once if needed.
  Future<Map<String, dynamic>> authorized(String method, String path, {Map<String, dynamic>? body}) async {
    try {
      return await call(method, path, body: body, accessToken: _accessToken);
    } on ApiError catch (e) {
      if (e.code != 'auth/token-expired' && e.code != 'auth/invalid-token') rethrow;
      await refresh();
      return call(method, path, body: body, accessToken: _accessToken);
    }
  }

  Future<void> refresh() => _refreshing ??= _refresh().whenComplete(() => _refreshing = null);

  Future<void> _refresh() async {
    final refreshToken = await _storage.read(key: 'refresh_token');
    if (refreshToken == null) throw ApiError('auth/invalid-token', 'Not signed in.');
    try {
      final json = await call('POST', '/auth/token/refresh', body: {'refresh_token': refreshToken});
      await _keep(json['data']);
    } on ApiError catch (e) {
      if (e.code == 'auth/refresh-token-revoked') await _forget(); // show the sign-in screen
      rethrow;
    }
  }

  Future<void> signOut() async {
    try {
      await authorized('POST', '/auth/sign-out');
    } finally {
      await _forget();
    }
  }

  Future<void> _keep(Map<String, dynamic> tokens) async {
    _accessToken = tokens['access_token'] as String;
    await _storage.write(key: 'refresh_token', value: tokens['refresh_token'] as String);
  }

  Future<void> _forget() async {
    _accessToken = null;
    await _storage.delete(key: 'refresh_token');
  }
}
// React Native: expo-secure-store (or react-native-keychain) keeps the refresh token.
import * as SecureStore from 'expo-secure-store';
import { call, ApiError } from './api';

let accessToken: string | undefined;
let refreshing: Promise<void> | undefined;

async function keep(tokens: { access_token: string; refresh_token: string }) {
  accessToken = tokens.access_token;
  await SecureStore.setItemAsync('refresh_token', tokens.refresh_token);
}

async function forget() {
  accessToken = undefined;
  await SecureStore.deleteItemAsync('refresh_token');
}

export async function signIn(email: string, password: string) {
  const { data } = await call('POST', '/auth/sign-in', { email, password });
  await keep(data);
  return data.user;
}

export function refresh(): Promise<void> {
  refreshing ??= (async () => {
    const refreshToken = await SecureStore.getItemAsync('refresh_token');
    if (!refreshToken) throw new ApiError('auth/invalid-token', 'Not signed in.');
    try {
      const { data } = await call('POST', '/auth/token/refresh', { refresh_token: refreshToken });
      await keep(data);
    } catch (e) {
      if (e instanceof ApiError && e.code === 'auth/refresh-token-revoked') await forget(); // show sign-in
      throw e;
    }
  })().finally(() => { refreshing = undefined; });
  return refreshing;
}

/** A call as the signed-in user, refreshing the tokens once if needed. */
export async function authorized<T = any>(method: string, path: string, body?: unknown): Promise<T> {
  try {
    return await call(method, path, body, accessToken);
  } catch (e) {
    if (!(e instanceof ApiError) || !['auth/token-expired', 'auth/invalid-token'].includes(e.code)) throw e;
    await refresh();
    return call(method, path, body, accessToken);
  }
}

export async function signOut() {
  try { await authorized('POST', '/auth/sign-out'); } finally { await forget(); }
}
// iOS: the refresh token lives in the Keychain; an actor serializes refreshes.
import Foundation
import Security

enum Keychain {
    private static func query(_ key: String) -> [String: Any] {
        [kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key]
    }
    static func save(_ value: String, for key: String) {
        SecItemDelete(query(key) as CFDictionary)
        var item = query(key)
        item[kSecValueData as String] = Data(value.utf8)
        item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        SecItemAdd(item as CFDictionary, nil)
    }
    static func read(_ key: String) -> String? {
        var item = query(key)
        item[kSecReturnData as String] = true
        item[kSecMatchLimit as String] = kSecMatchLimitOne
        var result: AnyObject?
        guard SecItemCopyMatching(item as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }
    static func delete(_ key: String) { SecItemDelete(query(key) as CFDictionary) }
}

actor Session {
    private var accessToken: String?
    private var refreshing: Task<Void, Error>?

    func signIn(email: String, password: String) async throws -> [String: Any] {
        let json = try await call("POST", "/auth/sign-in", body: ["email": email, "password": password])
        let data = json["data"] as! [String: Any]
        keep(data)
        return data["user"] as! [String: Any]
    }

    /// A call as the signed-in user, refreshing the tokens once if needed.
    func authorized(_ method: String, _ path: String, body: [String: Any]? = nil) async throws -> [String: Any] {
        do {
            return try await call(method, path, body: body, accessToken: accessToken)
        } catch let error as ApiError where error.code == "auth/token-expired" || error.code == "auth/invalid-token" {
            try await refresh()
            return try await call(method, path, body: body, accessToken: accessToken)
        }
    }

    func refresh() async throws {
        if let refreshing { return try await refreshing.value }
        let task = Task { try await self.performRefresh() }
        refreshing = task
        defer { refreshing = nil }
        try await task.value
    }

    private func performRefresh() async throws {
        guard let refreshToken = Keychain.read("refresh_token") else {
            throw ApiError(code: "auth/invalid-token", message: "Not signed in.")
        }
        do {
            let json = try await call("POST", "/auth/token/refresh", body: ["refresh_token": refreshToken])
            keep(json["data"] as! [String: Any])
        } catch let error as ApiError where error.code == "auth/refresh-token-revoked" {
            forget() // show the sign-in screen
            throw error
        }
    }

    func signOut() async {
        _ = try? await authorized("POST", "/auth/sign-out")
        forget()
    }

    private func keep(_ tokens: [String: Any]) {
        accessToken = tokens["access_token"] as? String
        if let refreshToken = tokens["refresh_token"] as? String { Keychain.save(refreshToken, for: "refresh_token") }
    }

    private func forget() {
        accessToken = nil
        Keychain.delete("refresh_token")
    }
}
// Android: keep the refresh token in storage encrypted with an Android Keystore key
// (for example Jetpack DataStore with Tink). A Mutex serializes refreshes.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONObject

interface TokenStore {
    suspend fun read(): String?
    suspend fun write(refreshToken: String?)
}

class Session(private val store: TokenStore) {
    @Volatile private var accessToken: String? = null
    private val refreshLock = Mutex()

    suspend fun signIn(email: String, password: String): JSONObject = withContext(Dispatchers.IO) {
        val data = call("POST", "/auth/sign-in", JSONObject().put("email", email).put("password", password)).getJSONObject("data")
        keep(data)
        data.getJSONObject("user")
    }

    /** A call as the signed-in user, refreshing the tokens once if needed. */
    suspend fun authorized(method: String, path: String, body: JSONObject? = null): JSONObject = withContext(Dispatchers.IO) {
        val used = accessToken
        try {
            call(method, path, body, used)
        } catch (e: ApiError) {
            if (e.code != "auth/token-expired" && e.code != "auth/invalid-token") throw e
            refresh(used)
            call(method, path, body, accessToken)
        }
    }

    /** Refreshes unless another call already did while this one waited. */
    suspend fun refresh(expired: String? = accessToken) = refreshLock.withLock {
        if (accessToken != expired) return@withLock
        val refreshToken = store.read() ?: throw ApiError("auth/invalid-token", "Not signed in.")
        try {
            keep(call("POST", "/auth/token/refresh", JSONObject().put("refresh_token", refreshToken)).getJSONObject("data"))
        } catch (e: ApiError) {
            if (e.code == "auth/refresh-token-revoked") forget() // show the sign-in screen
            throw e
        }
    }

    suspend fun signOut() {
        try { authorized("POST", "/auth/sign-out") } finally { forget() }
    }

    private suspend fun keep(tokens: JSONObject) {
        accessToken = tokens.getString("access_token")
        store.write(tokens.getString("refresh_token"))
    }

    private suspend fun forget() {
        accessToken = null
        store.write(null)
    }
}
// Web: the API uses tokens, not cookies. Anything script can read, an XSS bug can steal: keep the
// access token in memory, and the refresh token in sessionStorage (or memory only, and sign in
// again per tab) behind a strict Content-Security-Policy.
import { call, ApiError } from './api';

let accessToken: string | undefined;
let refreshing: Promise<void> | undefined;

function keep(tokens: { access_token: string; refresh_token: string }) {
  accessToken = tokens.access_token;
  sessionStorage.setItem('refresh_token', tokens.refresh_token);
}

function forget() {
  accessToken = undefined;
  sessionStorage.removeItem('refresh_token');
}

export async function signIn(email: string, password: string) {
  const { data } = await call('POST', '/auth/sign-in', { email, password });
  keep(data);
  return data.user;
}

export function refresh(): Promise<void> {
  refreshing ??= (async () => {
    const refreshToken = sessionStorage.getItem('refresh_token');
    if (!refreshToken) throw new ApiError('auth/invalid-token', 'Not signed in.');
    try {
      keep((await call('POST', '/auth/token/refresh', { refresh_token: refreshToken })).data);
    } catch (e) {
      if (e instanceof ApiError && e.code === 'auth/refresh-token-revoked') forget(); // show sign-in
      throw e;
    }
  })().finally(() => { refreshing = undefined; });
  return refreshing;
}

export async function authorized<T = any>(method: string, path: string, body?: unknown): Promise<T> {
  try {
    return await call(method, path, body, accessToken);
  } catch (e) {
    if (!(e instanceof ApiError) || !['auth/token-expired', 'auth/invalid-token'].includes(e.code)) throw e;
    await refresh();
    return call(method, path, body, accessToken);
  }
}

export async function signOut() {
  try { await authorized('POST', '/auth/sign-out'); } finally { forget(); }
}

Refreshing tokens

POST /auth/token/refresh with {"refresh_token": "…"} returns a new access token and a new refresh token; store the new one and discard the old. Using an old refresh token again after 30 seconds is treated as theft: the whole session ends and every later refresh answers auth/refresh-token-revoked. That is also the answer after sign-out, a password reset, or when the account was disabled: show the sign-in screen.

Signing out

  • POST /auth/sign-out ends this device's session; POST /auth/sign-out-all ends every session of the user in this app. Both answer 204.
  • The user can also see and end sessions one by one: Account → Devices.
  • Register the push token again after signing out, so the device stops receiving the user's notifications (Push).

Google

Set the app's Google client IDs (web, iOS and Android) on the dashboard's Settings page. In the app, sign in with Google's own SDK and send its ID token:

Platform Where the ID token comes from
Flutter google_sign_in: the account's authentication.idToken
React Native @react-native-google-signin/google-signin: idToken of the sign-in result
iOS GoogleSignIn: result.user.idToken?.tokenString
Android Credential Manager: GoogleIdTokenCredential.idToken
Web Google Identity Services: the credential of the callback
const { data } = await call('POST', '/auth/google', { id_token: idToken, nonce });
// data.is_new_user is true when the account was just created

If an account with the same, verified, email exists, Google is linked to it. If that account's email isn't verified, the answer is 409 auth/account-exists-with-different-credential: sign in the usual way first, then link Google.

Apple

Set the app's Apple Services ID or bundle ID, team ID, key ID and .p8 key on the Settings page. Send the identity token, the authorization code (so the account's Apple tokens can be revoked when it is deleted, which the App Store requires), and on the first sign-in the name, which Apple gives only once. Use a nonce: send its SHA-256 to Apple and the raw value here.

// iOS: from ASAuthorizationControllerDelegate
import AuthenticationServices

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
    guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
          let identityToken = credential.identityToken.flatMap({ String(data: $0, encoding: .utf8) }),
          let code = credential.authorizationCode.flatMap({ String(data: $0, encoding: .utf8) }) else { return }

    Task {
        let json = try await call("POST", "/auth/apple", body: [
            "identity_token": identityToken,
            "authorization_code": code,
            "nonce": rawNonce, // the value whose SHA-256 you set on the request
            "first_name": credential.fullName?.givenName ?? "",
            "last_name": credential.fullName?.familyName ?? "",
        ])
        // keep json["data"] as in Session.keep
    }
}

On Android and the web, Sign in with Apple JS (or a plugin such as sign_in_with_apple for Flutter and @invertase/react-native-apple-authentication) gives the same id_token/identityToken and code.

Email codes (passwordless)

  1. POST /auth/email-code/send with {"email"} emails a 6-digit code, valid 10 minutes. The answer is the same whether or not the email has an account. When the app has a deep-link URL, the email also has a magic link that opens it: <deep link>?mode=sign_in&email=…&code=…. Read email and code from the link and verify them as below.
  2. POST /auth/email-code/verify with {"email", "code"} (and first_name, last_name for a new account) signs in, creating the account if needed. The email counts as verified.

A code dies after 5 wrong guesses (auth/invalid-code) or 10 minutes (auth/code-expired).

Forgotten password

  1. POST /auth/password/forgot with {"email"} emails a 6-digit code (same answer for every email).
  2. POST /auth/password/reset with {"email", "code", "password"} sets the new password (204) and ends every session of the account. Sign in again.

Verifying tokens on your server

Your backend can check access tokens offline against the public keys, or ask the service. See Your backend.