# 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](https://www.rfc-editor.org/rfc/rfc8252)).

- **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](/docs/api-reference/openapi/).

## 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](/docs/oauth/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](/docs/get-started/authentication/).

## Step 1 — Install

```bash
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`:

```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:

```ts
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:

```ts
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:

```tsx
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`:

```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](/docs/oauth/token/) 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](/docs/api-reference/passes/).

| Call | Scope | What it does |
|---|---|---|
| `GET /api/oauth/userinfo` | `openid` | Who is signed in (`sub`, `email`, `name`, `picture`) |
| `GET /api/v1/passes?limit&cursor&search` | `passes:read` | The user's guest passes, newest first. Page shape: `{ items, total, next_cursor, has_more, usage }` — `usage` is their monthly invite allowance |
| `POST /api/v1/passes` | `passes:write` | Create 1–10 passes → `201 { public_id, qr_values, pass_link, qr_sent, sms_sent }` |
| `GET /api/v1/passes/{public_id}` | `passes:read` | One pass (status: `expected → registered → checked_in → checked_out`, or `cancelled`) |
| `DELETE /api/v1/passes/{public_id}` | `passes:write` | Revoke an active pass (kills its QR on the doors too) |
| `GET /api/v1/invites` | `invites:read` | Org 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`:

```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](/docs/api-reference/passes/)):

```ts
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`:

```bash
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](/docs/api-reference/conventions/).

## Step 5 — Errors, branch on the code

| Status / `error` | Meaning | App behavior |
|---|---|---|
| `401 invalid_token` | Access token expired or invalid | Refresh, retry once; if refresh fails → re-login |
| `400 invalid_grant` (token endpoint) | Code/refresh expired, reused, or mismatched | Clear tokens → re-login |
| `403 insufficient_scope` | Token lacks the scope, or the user's org role was removed | Don't retry; show "not available for your account" |
| `402` (create pass) | Monthly invite allowance exhausted — body has `limit` / `current` | Show the quota message |
| `400 / 404 / 409` (create/revoke) | Validation / not found / pass no longer active | Show the message from the body |
| `429` | Rate limited | Back off and retry |

Error text is deliberately generic — **branch on the code, never parse the prose**. Full catalog: [Errors](/docs/api-reference/errors/).

## Step 6 — Logout

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

```ts
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](/docs/oauth/logout/).

## Nice-to-knows

- The `id_token` carries **`amr`** — *how* 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](/docs/oauth/id-token-userinfo/).
- The authorize redirect also carries **`iss`** ([RFC 9207](https://www.rfc-editor.org/rfc/rfc9207)) — 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/*`](/docs/api-reference/organization/) — 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