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_idis not an API key. Theclient_id(eb_…, nosk) 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-browserDeclare 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 returnsinvalid_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.
| 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:
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 emailedThe 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 / 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.
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_tokencarriesamr— how the user signed in (["swk","user"]= passkey,["pwd","otp","mfa"]= password + code,["fed"]= Google) — andauth_time. For a sensitive screen, request a fresh login withprompt=login(ormax_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;
schemeset inapp.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_granton 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