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_inseconds). Send it asAuthorization: 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-outends this device's session;POST /auth/sign-out-allends every session of the user in this app. Both answer204.- 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).
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)
POST /auth/email-code/sendwith{"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=…. Reademailandcodefrom the link and verify them as below.POST /auth/email-code/verifywith{"email", "code"}(andfirst_name,last_namefor 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
POST /auth/password/forgotwith{"email"}emails a 6-digit code (same answer for every email).POST /auth/password/resetwith{"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.