Skip to content

Quickstart: Sign in with EntryBit

העתקת עמוד

Add Sign in with EntryBit to a React Native (Expo) app in minutes — register a public client, run the PKCE flow with expo-auth-session, exchange the code, and call the API.

עודכן

This is the short path to “Sign in with EntryBit” in a React Native app and calling the API on the user’s behalf. It uses expo-auth-session, the authorization-code flow, and PKCE (S256) — the standard, secure pattern for native apps (RFC 8252).

  • Base URL: https://entrybit.net (or your staging origin)
  • Auth model: authorization-code + PKCE via the system browser — never a WebView, never an in-app password screen.
  • Everything self-describes: GET /.well-known/openid-configuration

For the full walkthrough with raw HTTP and the why behind each choice, the endpoint reference is the OpenAPI spec.

Step 0 — Register a public client (one-time)

Register a public OAuth client in Settings → Team → OAuth apps — self-service, any org admin can do it. See Register an app for the full field reference.

  • Application type: Public (mobile / SPA — no secret; PKCE is the proof)
  • Redirect URI: entrybitresident://oauth/callback
  • Scopes: openid profile email offline_access passes:read passes:write invites:read

You get a client_id like eb_9f1c… immediately. That, plus the base URL, is everything the app needs.

client_id is not an API key. The client_id (eb_…, no sk) is public and belongs in the app. An API key (eb_sk_…, from Settings → API keys) is a server secret — never put it in an app; anyone can extract it from an APK or bundle. See Authentication.

Step 1 — Install

npx expo install expo-auth-session expo-crypto expo-secure-store expo-web-browser

Declare the custom scheme (the entrybitresident:// part of your redirect URI) in app.json:

{
  "expo": {
    "scheme": "entrybitresident"
  }
}

OAuth with a custom scheme does not work in Expo Go — use a development build (npx expo run:ios / run:android, or an EAS dev client). Do not route OAuth through the old AuthSession proxy.

Step 2 — Sign in

Three small modules keep this testable: shared config, a secure token store, and the sign-in screen. All of the code below is complete — paste it in and change the CLIENT_ID.

lib/entrybit.ts — one place for config and the discovery document:

import * as AuthSession from 'expo-auth-session';

export const ISSUER = 'https://entrybit.net';
export const CLIENT_ID = 'eb_9f1c2ab34cd56ef7'; // from step 0 — public, not a secret

export const redirectUri = AuthSession.makeRedirectUri({
  scheme: 'entrybitresident',
  path: 'oauth/callback',
});

export const SCOPES = [
  'openid', 'profile', 'email', 'offline_access',
  'passes:read', 'passes:write', 'invites:read',
];

// The same document useAutoDiscovery() reads, cached for non-hook code.
let discovery: AuthSession.DiscoveryDocument | undefined;

export async function getDiscovery(): Promise<AuthSession.DiscoveryDocument> {
  return (discovery ??= await AuthSession.fetchDiscoveryAsync(ISSUER));
}

lib/tokenStore.ts — the access token lives ~15 minutes, so it stays in memory; only the durable credentials touch the device keychain:

import * as SecureStore from 'expo-secure-store';
import type { TokenResponse } from 'expo-auth-session';

const REFRESH_TOKEN_KEY = 'entrybit.refreshToken'; // the durable credential
const ID_TOKEN_KEY = 'entrybit.idToken';           // kept only for logout (id_token_hint)

let accessToken: string | null = null;
let accessTokenExpiresAt = 0; // epoch ms

export async function saveTokens(t: TokenResponse): Promise<void> {
  accessToken = t.accessToken;
  accessTokenExpiresAt = (t.issuedAt + (t.expiresIn ?? 900)) * 1000;

  // Rotation rule: every refresh returns a NEW refresh token — always overwrite.
  if (t.refreshToken) await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, t.refreshToken);
  if (t.idToken) await SecureStore.setItemAsync(ID_TOKEN_KEY, t.idToken);
}

export function getFreshAccessToken(): string | null {
  return Date.now() < accessTokenExpiresAt - 30_000 ? accessToken : null; // 30 s clock skew
}

export const getRefreshToken = () => SecureStore.getItemAsync(REFRESH_TOKEN_KEY);
export const getIdToken = () => SecureStore.getItemAsync(ID_TOKEN_KEY);

export async function clearTokens(): Promise<void> {
  accessToken = null;
  accessTokenExpiresAt = 0;
  await Promise.all([
    SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY),
    SecureStore.deleteItemAsync(ID_TOKEN_KEY),
  ]);
}

Tokens belong in expo-secure-store (Keychain / Android Keystore) or in memory — never AsyncStorage, never logs, never Redux state persisted to disk.

app/sign-in.tsx — the screen. The hook opens the system browser; the effect exchanges the returned code for tokens:

import { useEffect, useRef, useState } from 'react';
import { Button, Text, View } from 'react-native';
import * as AuthSession from 'expo-auth-session';
import { CLIENT_ID, ISSUER, redirectUri, SCOPES } from '../lib/entrybit';
import { saveTokens } from '../lib/tokenStore';

export default function SignInScreen({ onSignedIn }: { onSignedIn: () => void }) {
  // Configures every endpoint from /.well-known/openid-configuration.
  const discovery = AuthSession.useAutoDiscovery(ISSUER);
  const [error, setError] = useState<string | null>(null);
  const exchangedCode = useRef<string | null>(null);

  const [request, response, promptAsync] = AuthSession.useAuthRequest(
    {
      clientId: CLIENT_ID,
      redirectUri,
      responseType: 'code',
      usePKCE: true, // S256 — the server rejects requests without it
      scopes: SCOPES,
    },
    discovery,
  );

  useEffect(() => {
    // 'cancel' / 'dismiss' mean the user backed out — nothing to do.
    if (!discovery || !request?.codeVerifier || response?.type !== 'success') return;

    // An authorization code is single-use — exchanging it twice revokes the
    // grant — so guard against effect re-runs before exchanging.
    const { code } = response.params;
    if (exchangedCode.current === code) return;
    exchangedCode.current = code;

    AuthSession.exchangeCodeAsync(
      {
        clientId: CLIENT_ID,
        code,
        redirectUri,
        extraParams: { code_verifier: request.codeVerifier },
      },
      discovery,
    )
      .then(saveTokens) // { accessToken, refreshToken, idToken, expiresIn: 900 }
      .then(onSignedIn)
      .catch(() => setError('Sign-in failed. Please try again.'));
  }, [discovery, request, response, onSignedIn]);

  return (
    <View>
      <Button
        title="Sign in with EntryBit"
        disabled={!request} // null until discovery and the PKCE pair are ready
        onPress={() => promptAsync()}
      />
      {response?.type === 'error' && (
        <Text>{response.error?.description ?? 'Sign-in was rejected.'}</Text>
      )}
      {error && <Text>{error}</Text>}
    </View>
  );
}

The 2FA step (SMS, authenticator, or passkey) happens inside the browser on the EntryBit login page — the app does nothing special for it.

Step 3 — Token rules (the two that matter)

  • Access token lives ~15 minutes. Send it as Authorization: Bearer <token>. Treat it as opaque.
  • Refresh tokens rotate. Every refresh returns a new refresh_token — always overwrite the stored one. Replaying an old one revokes the whole family (anti-theft) and returns invalid_grant → clear tokens and re-login.

Both rules live in one function. Note the single-flight guard: refresh tokens are single-use, so two parallel refreshes look like theft and revoke the whole family — concurrent callers must share one refresh.

lib/session.ts:

import * as AuthSession from 'expo-auth-session';
import { CLIENT_ID, getDiscovery } from './entrybit';
import { clearTokens, getFreshAccessToken, getRefreshToken, saveTokens } from './tokenStore';

/** Thrown when no session is left — route to the sign-in screen. */
export class SignedOutError extends Error {
  constructor() {
    super('Signed out');
    this.name = 'SignedOutError';
  }
}

let refreshInFlight: Promise<string> | null = null;

/** Returns a valid access token, refreshing if needed. Safe to call from anywhere. */
export async function getAccessToken({ forceRefresh = false } = {}): Promise<string> {
  if (!forceRefresh) {
    const fresh = getFreshAccessToken();
    if (fresh) return fresh;
  }
  refreshInFlight ??= refresh().finally(() => {
    refreshInFlight = null;
  });
  return refreshInFlight;
}

async function refresh(): Promise<string> {
  const refreshToken = await getRefreshToken();
  if (!refreshToken) throw new SignedOutError();

  try {
    const tokens = await AuthSession.refreshAsync(
      { clientId: CLIENT_ID, refreshToken },
      await getDiscovery(),
    );
    await saveTokens(tokens); // persists the ROTATED refresh token
    return tokens.accessToken;
  } catch (err) {
    if (err instanceof AuthSession.TokenError && err.code === 'invalid_grant') {
      // Expired, revoked, or reuse-detected — the session is over.
      await clearTokens();
      throw new SignedOutError();
    }
    throw err; // network blip — keep the tokens and let the caller retry
  }
}

Catch SignedOutError at your navigation layer and show the sign-in screen. See the Token endpoint for the full rotation and reuse-detection contract.

Step 4 — Call the API

All under https://entrybit.net, all Authorization: Bearer <access_token>, all JSON. Full schemas are in the API reference.

CallScopeWhat it does
GET /api/oauth/userinfoopenidWho is signed in (sub, email, name, picture)
GET /api/v1/passes?limit&cursor&searchpasses:readThe user’s guest passes, newest first. Page shape: { items, total, next_cursor, has_more, usage }usage is their monthly invite allowance
POST /api/v1/passespasses:writeCreate 1–10 passes → 201 { public_id, qr_values, pass_link, qr_sent, sms_sent }
GET /api/v1/passes/{public_id}passes:readOne pass (status: expected → registered → checked_in → checked_out, or cancelled)
DELETE /api/v1/passes/{public_id}passes:writeRevoke an active pass (kills its QR on the doors too)
GET /api/v1/invitesinvites:readOrg invitations addressed to the user

A thin wrapper gives every call the Bearer header and the one retry that is safe — refresh-and-retry on 401.

lib/api.ts:

import { getAccessToken } from './session';

const BASE_URL = 'https://entrybit.net';

export class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly code: string,  // machine-readable — branch on this
    message: string,        // human-readable — display only
    readonly body: unknown, // full payload (e.g. 402 quota details)
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
  let res = await send(path, init, await getAccessToken());

  if (res.status === 401) {
    // The token died early (e.g. revoked) — refresh once, retry once.
    res = await send(path, init, await getAccessToken({ forceRefresh: true }));
  }
  if (!res.ok) {
    const body = (await res.json().catch(() => ({}))) as {
      code?: string; error?: string; message?: string;
    };
    throw new ApiError(
      res.status,
      body.code ?? body.error ?? String(res.status),
      body.message ?? body.error ?? `HTTP ${res.status}`,
      body,
    );
  }
  return res.json() as Promise<T>;
}

function send(path: string, init: RequestInit, token: string): Promise<Response> {
  return fetch(`${BASE_URL}${path}`, {
    ...init,
    headers: {
      Accept: 'application/json',
      ...(init.body ? { 'Content-Type': 'application/json' } : {}),
      ...init.headers,
      Authorization: `Bearer ${token}`,
    },
  });
}

Using it — list the user’s passes, then create one (full shapes in the API reference):

import { api } from '../lib/api';

type PassPage = {
  items: Array<{ public_id: string; first_name: string; status: string; arrival_date: string }>;
  next_cursor: string | null;
  has_more: boolean;
};

const page = await api<PassPage>('/api/v1/passes?limit=30');

const created = await api<{ public_id: string; pass_link: string | null; qr_sent: boolean }>(
  '/api/v1/passes',
  {
    method: 'POST',
    body: JSON.stringify({
      first_name: 'Dana',
      email: 'dana@example.com',
      arrival_date: '2026-07-12',
      arrival_time: '14:30',
      facility_id: 42,
      quantity: 1,
    }),
  },
);
console.log(created.pass_link); // one shareable link — the QR is already emailed

The same call from a terminal, with an access token exported as $ACCESS_TOKEN:

curl -X POST https://entrybit.net/api/v1/passes \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Dana",
    "email": "dana@example.com",
    "arrival_date": "2026-07-12",
    "arrival_time": "14:30",
    "facility_id": 42,
    "quantity": 1
  }'

Pagination: pass the previous page’s next_cursor back as cursor until has_more is false — a ready-made loop is in Conventions.

Step 5 — Errors, branch on the code

Status / errorMeaningApp behavior
401 invalid_tokenAccess token expired or invalidRefresh, retry once; if refresh fails → re-login
400 invalid_grant (token endpoint)Code/refresh expired, reused, or mismatchedClear tokens → re-login
403 insufficient_scopeToken lacks the scope, or the user’s org role was removedDon’t retry; show “not available for your account”
402 (create pass)Monthly invite allowance exhausted — body has limit / currentShow the quota message
400 / 404 / 409 (create/revoke)Validation / not found / pass no longer activeShow the message from the body
429Rate limitedBack off and retry

Error text is deliberately generic — branch on the code, never parse the prose. Full catalog: Errors.

Step 6 — Logout

lib/signOut.ts — revoke the grant, forget the tokens, and (on shared devices) end the browser session too:

import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
import { CLIENT_ID, getDiscovery, redirectUri } from './entrybit';
import { clearTokens, getIdToken, getRefreshToken } from './tokenStore';

export async function signOut({ endBrowserSession = false } = {}): Promise<void> {
  const [discovery, refreshToken, idToken] = await Promise.all([
    getDiscovery(),
    getRefreshToken(),
    getIdToken(),
  ]);

  // 1. Revoke the grant — kills the whole rotating refresh-token family.
  //    Best-effort: local sign-out must still complete offline.
  if (refreshToken) {
    await AuthSession.revokeAsync(
      {
        token: refreshToken,
        clientId: CLIENT_ID,
        tokenTypeHint: AuthSession.TokenTypeHint.RefreshToken,
      },
      discovery,
    ).catch(() => {});
  }

  // 2. Forget everything locally.
  await clearTokens();

  // 3. Shared device? Also end the EntryBit browser session, so the next
  //    /authorize re-prompts for credentials instead of reusing the cookie.
  if (endBrowserSession && idToken && discovery.endSessionEndpoint) {
    const logoutUrl = `${discovery.endSessionEndpoint}?${new URLSearchParams({
      id_token_hint: idToken,
      post_logout_redirect_uri: redirectUri, // must be registered as a post-logout URI
    })}`;
    await WebBrowser.openAuthSessionAsync(logoutUrl, redirectUri);
  }
}

Alternatively, skip step 3 entirely by signing in with promptAsync({ preferEphemeralSession: true }) (iOS) so no browser cookie ever persists.

RP-initiated logout only terminates the browser session when the id_token_hint verifies and matches the signed-in user, and only redirects to a registered post-logout URI. See Logout.

Nice-to-knows

  • The id_token carries amrhow the user signed in (["swk","user"] = passkey, ["pwd","otp","mfa"] = password + code, ["fed"] = Google) — and auth_time. For a sensitive screen, request a fresh login with prompt=login (or max_age=300). See ID token & UserInfo.
  • The authorize redirect also carries iss (RFC 9207) — your library may verify it automatically; safe to ignore otherwise.
  • Server-to-server work (a backend acting for the whole organization) uses API keys on /api/v1/org/* — not the mobile app’s concern, but know it exists.

Ship checklist

  • Public client registered with the exact redirect URI; scheme set in app.json
  • System browser via expo-auth-session (no WebView), PKCE on
  • Refresh/id tokens only in SecureStore, access token in memory; the rotated refresh token overwritten on every refresh
  • Refresh is single-flight — concurrent callers share one refresh, never two in parallel
  • invalid_grant on refresh → clear tokens → login screen
  • 401 → one refresh-and-retry; 403 / 402 → user-facing message
  • Logout revokes the refresh token; ephemeral session on shared devices