@glideidentity/glide-fe-sdk-web

Glide Phone Authentication SDK for web applications
2,772
Install
npm install @glideidentity/glide-fe-sdk-web
Latest Version:3.2.0
Vue:3
License:Apache-2.0
Last Updated:Oct 8, 2026
Links: npm
Maintainer: nivo-glide

Glide Web Client SDK

The official web SDK for integrating Glide's carrier-grade phone verification into your web applications.

Features

  • Instant Verification — Direct carrier verification without SMS delays
  • Fraud Resistant — Can't be intercepted or spoofed like SMS codes
  • Strategy-driven — One API across ts43 (Android Digital Credentials), link (mobile-web App Clip / native App Link), and desktop (cross-device QR); Glide's Magical Auth service selects the strategy server-side from the carrier + your user agent and returns it in prepare
  • Customizable UI — Built-in modal with themes (single universal QR on desktop)
  • Flexible Architecture — Use the full SDK or just the core types
  • Framework Support — React, Vue, and vanilla JavaScript
  • Tree-Shakeable — Import only what you need
  • SIM-swap & device-swap signals — first-party carrier risk metadata exposed alongside the verified phone number

Installation

npm install @glideidentity/glide-fe-sdk-web

Quick Start

Two integration shapes ship with the SDK. The Granular API is what we recommend for production — it lets you call prepare() early so the carrier prompt opens with no perceptible delay when the user clicks the CTA. The High-Level API (authenticate()) bundles all three steps into one call and is the fastest way to see the SDK working end-to-end the first time you wire it up.

Recommended: Granular API

prepare → invokeSecurePrompt → verifyPhoneNumber (or getPhoneNumber) as three separate calls. The win is eager prepare: kick the network round-trip off as soon as you have enough context (e.g. when the user types their phone number, when the input field gains focus, or right after step navigation lands the user on the verification screen). By the time the user actually taps the verify button, the prepare response is already cached locally — invokeSecurePrompt() opens the carrier UI immediately, with no spinner.

The browser's user-gesture security model means invokeSecurePrompt() itself must still run from a synchronous click handler. Eager prepare doesn't change that — it just removes the network wait that would otherwise sit between the click and the carrier prompt.

import { PhoneAuthClient, USE_CASE } from '@glideidentity/glide-fe-sdk-web';

const client = new PhoneAuthClient({
  endpoints: {
    prepare: '/api/magical-auth/prepare',
    reportInvocation: '/api/magical-auth/report-invocation',
    process: '/api/magical-auth/process',
  }
});

// Step 1: Call prepare() AS EARLY AS YOU HAVE THE PHONE NUMBER.
//   e.g. on input blur, on form-step transition, or right after the user types it.
//   This pre-pays the prepare round-trip so the carrier prompt opens instantly later.
const prepared = await client.prepare({
  use_case: USE_CASE.VERIFY_PHONE_NUMBER,
  phone_number: '+14155551234',
});

// Step 2: From a click handler — must be a real user gesture.
verifyBtn.addEventListener('click', async () => {
  const invokeResult = await client.invokeSecurePrompt(prepared);  // opens carrier UI immediately
  const credential = await invokeResult.credential;

  // Step 3: Hand the credential to your backend (which calls Glide's verify-phone-number / get-phone-number).
  const result = await client.verifyPhoneNumber(credential, invokeResult.session);
  console.log('Verified:', result.verified);
});

High-Level API (quickest path to "it works")

authenticate() runs prepare → invokeSecurePrompt → verifyPhoneNumber (or getPhoneNumber) for you in a single call. Easiest to integrate the first time and great for demos / spikes — the trade-off is that the carrier prompt won't open until the prepare round-trip completes inside the click handler, so on slower networks the user sees a brief delay between tap and carrier UI.

import { PhoneAuthClient, USE_CASE } from '@glideidentity/glide-fe-sdk-web';

const client = new PhoneAuthClient({
  endpoints: {
    prepare: '/api/magical-auth/prepare',
    reportInvocation: '/api/magical-auth/report-invocation',
    process: '/api/magical-auth/process',
  }
});

verifyBtn.addEventListener('click', async () => {
  // Get a phone number (use_case omitted for VERIFY_PHONE_NUMBER + a phone_number)
  const result = await client.authenticate({
    use_case: USE_CASE.GET_PHONE_NUMBER
  });
  console.log('Phone:', result.phone_number);
});

Configuration

Client Options

const client = new PhoneAuthClient({
  // Backend endpoints (with defaults shown)
  endpoints: {
    prepare: '/api/magical-auth/prepare',              // Prepare request endpoint
    process: '/api/magical-auth/process',              // Process credential endpoint (your backend long-polls Magical Auth)
    reportInvocation: '/api/magical-auth/report-invocation', // ASR tracking endpoint
    eligibility: '/api/magical-auth/eligibility',      // Eligibility check endpoint (optional)
  },

  timeout: 30000,             // API request timeout in ms (default: 30000)
  sessionTimeout: 300000,     // Max wait for desktop session completion — QR scan → mobile auth → backend `/process` chain (default: 5 min)

  // Strategy override — pin a specific strategy instead of letting the server auto-select.
  // Must be one of the strategies returned by checkEligibility() for the target phone number.
  // If not available, prepare() returns a STRATEGY_NOT_AVAILABLE error.
  authenticationStrategy: 'ts43', // Optional: 'ts43' | 'link' | 'desktop' (default: auto-selected by server)

  // Debug options
  debug: false,               // Enable console logging (default: false)
  devtools: {
    showMobileConsole: false  // Show on-screen console on mobile (default: false)
  },
});

Invoke Options

Customize the authentication UI and behavior:

const result = await client.invokeSecurePrompt(prepared, {
  // Prevent SDK from showing any UI (use your own)
  preventDefaultUI: false,

  // CSP nonce for the elements the SDK injects (the modal <style>)
  nonce: cspNonce,

  // Modal customization (for desktop QR strategy)
  modalOptions: {
    theme: 'auto',              // 'dark' | 'light' | 'auto'
    title: 'Scan to Verify',    // Custom title text
    description: '',            // Optional subtitle shown below the QR
    showCloseButton: true,      // Show X button
    closeOnBackdropClick: true, // Close on outside click
    closeOnEscape: true,        // Close on Escape key
    qrRenderer: undefined,      // (container, qrImage) => void — replace the data: <img>
  }
});

Modal Customization

The SDK provides a built-in modal for desktop QR code display. It renders a single universal QR code — the Mobile Auth companion app detects the device platform at runtime, so there is no OS toggle / picker on desktop.

Themes

Theme Description
auto Follows system preference (default)
dark Dark background with light text
light Light background with dark text

Custom UI

To use your own UI instead of the built-in modal:

import { getDesktopQRImage } from '@glideidentity/glide-fe-sdk-web';

const result = await client.invokeSecurePrompt(prepared, {
  preventDefaultUI: true
});

if (result.strategy === 'desktop') {
  const qrImage = getDesktopQRImage(prepared.data);
  const url = prepared.data.data.url;
  displayYourQRCode(qrImage, url);

  const credential = await result.credential;
  // result.cancel?.() to abort the flow
}

Authentication Strategies

Glide's Magical Auth service selects the strategy server-side based on the carrier (looked up from the phone number / PLMN) and your user agent (platform, browser, native vs mobile-web), then returns it in prepare as authentication_strategy. Branch your code on the returned value, not on navigator.userAgent or browser sniffing — Glide has already factored in the OS / browser, and the same OS may receive different strategies for different carriers.

Strategy Where it runs Mechanism
ts43 Android Chrome 128+ Native Digital Credentials API hand-off via the platform Credential Manager.
link Any mobile browser whose carrier uses an App Clip / App Link redirect-with-code handoff The Web SDK runs the mobile-web mode of link: opens the carrier App Clip in a new tab, the result returns via a completion-page redirect, device binding uses an HttpOnly cookie (fe_code) plus a URL fragment (agg_code), and the original tab observes a localStorage signal to retrieve the result. (The native return-channel mode of link — Universal Link / App Link with Secure Enclave–backed binding — is handled by the React Native and Android SDKs, not this one.)
desktop Desktop browsers QR code scanned by a Glide-aware mobile companion.

Strategy-Specific Handling

const result = await client.invokeSecurePrompt(prepared);

switch (result.strategy) {
  case 'ts43':
    // Android: Credential returned directly
    const credential = await result.credential;
    break;
    
  case 'link':
    // Mobile-web mode of `link`: SDK opens the carrier App Clip in a new tab,
    // listens for the completion-page `localStorage` signal, then resolves the credential.
    const credential = await result.credential;
    // Optionally cancel the in-flight flow
    result.cancel?.();
    break;
    
  case 'desktop':
    // Desktop: QR modal; credential resolves when mobile completes auth (backend `/process` long-poll)
    const credential = await result.credential;
    result.cancel?.();
    break;
}

Framework Integration

React

import { usePhoneAuth, USE_CASE } from '@glideidentity/glide-fe-sdk-web/react';

function PhoneVerification() {
  const { authenticate, isLoading, error, result } = usePhoneAuth({
    endpoints: {
      prepare: '/api/magical-auth/prepare',
      reportInvocation: '/api/magical-auth/report-invocation',
      process: '/api/magical-auth/process',
    }
  });

  const handleVerify = async () => {
    try {
      const result = await authenticate({
        use_case: USE_CASE.VERIFY_PHONE_NUMBER,
        phone_number: '+14155551234'
      });
      console.log('Verified:', result.verified);
    } catch (err) {
      console.error('Failed:', err);
    }
  };

  return (
    <button onClick={handleVerify} disabled={isLoading}>
      {isLoading ? 'Verifying...' : 'Verify Phone'}
    </button>
  );
}

Vue

<script setup>
import { usePhoneAuth, USE_CASE } from '@glideidentity/glide-fe-sdk-web/vue';

const { authenticate, isLoading, error, result } = usePhoneAuth({
  endpoints: {
    prepare: '/api/magical-auth/prepare',
    reportInvocation: '/api/magical-auth/report-invocation',
    process: '/api/magical-auth/process',
  }
});

const handleVerify = async () => {
  await authenticate({
    use_case: USE_CASE.VERIFY_PHONE_NUMBER,
    phone_number: '+14155551234'
  });
};
</script>

<template>
  <button @click="handleVerify" :disabled="isLoading">
    {{ isLoading ? 'Verifying...' : 'Verify Phone' }}
  </button>
</template>

Vanilla JavaScript (Browser)

<script src="https://unpkg.com/@glideidentity/glide-fe-sdk-web/dist/browser/web-client-sdk.min.js"></script>
<script>
  const { PhoneAuthClient, USE_CASE } = GlideWebClientSDK;
  
  const client = new PhoneAuthClient({
    endpoints: {
      prepare: '/api/magical-auth/prepare',
      reportInvocation: '/api/magical-auth/report-invocation',
      process: '/api/magical-auth/process',
    }
  });
  
  document.getElementById('verify-btn').onclick = async () => {
    const result = await client.authenticate({
      use_case: USE_CASE.VERIFY_PHONE_NUMBER,
      phone_number: '+14155551234'
    });
    console.log('Verified:', result.verified);
  };
</script>

Script-tag install (no bundler)

Two browser bundles ship in dist/browser/. Pick by what the host page can execute:

File Syntax Module wrapper Use when
web-client-sdk.min.js ES2020 UMD (AMD / CommonJS / global) Default. Modern pages without a module loader on the page.
web-client-sdk.es5.min.js ES5 None — always sets window.GlideWebClientSDK The page ships ES5, runs an ES5-only minifier or optimizer (UglifyJS 1.x, r.js), or has RequireJS / another AMD loader loaded. The UMD file registers an anonymous define() when it sees define.amd, which RequireJS rejects with Mismatched anonymous define(); the ES5 file has no AMD or CommonJS branch, so a plain <script> tag is safe.

The ES5 file is a syntax downlevel only — no polyfills are bundled. The browser still needs fetch, Promise, localStorage and AbortController at runtime; that is every browser the strategies above support.

Vendoring. Copy the file out of node_modules at build time rather than hot-linking a CDN, so the version you tested is the version you serve:

// build step (Node)
const fs = require('fs');
const path = require('path');
const sdkDir = path.dirname(require.resolve('@glideidentity/glide-fe-sdk-web/package.json'));
const { version } = require('@glideidentity/glide-fe-sdk-web/package.json');
fs.copyFileSync(
  path.join(sdkDir, 'dist/browser/web-client-sdk.es5.min.js'),
  `static/glide-web-sdk-${version}.min.js`,
);

Both @glideidentity/glide-fe-sdk-web/package.json and @glideidentity/glide-fe-sdk-web/dist/browser/* are in the package exports map, so require.resolve works under strict exports enforcement; reading package.json off disk with fs from the resolved directory works as well. unpkg / jsdelivr point at the UMD file (https://cdn.jsdelivr.net/npm/@glideidentity/glide-fe-sdk-web/dist/browser/web-client-sdk.es5.min.js for the ES5 one).

Strict CSP (nonce-only script-src / style-src). Put the page nonce on the SDK <script> tag and pass the same nonce to invokeSecurePrompt, so the desktop modal's injected <style> carries it:

<script nonce="{{cspNonce}}" src="/static/glide-web-sdk-3.2.0.min.js"></script>
<script nonce="{{cspNonce}}">
  var client = new GlideWebClientSDK.PhoneAuthClient({
    endpoints: {
      prepare: '/api/magical-auth/prepare',
      process: '/api/magical-auth/process',
      reportInvocation: '/api/magical-auth/report-invocation' // keep on — see below
    }
  });
  document.getElementById('verify-btn').addEventListener('click', function () {
    client.invokeSecurePrompt(prepared, {
      nonce: '{{cspNonce}}',          // stamped on every element the SDK injects
      modalOptions: {
        // Optional: draw the QR yourself when img-src blocks data: URIs.
        qrRenderer: function (container, qrImage) { renderQrSvg(container, prepared.data.data.url); }
      }
    }).then(function (result) { return result.credential; });
  });
</script>
  • nonce covers the modal's <style>. The QR itself is a data: PNG <img>; if the page's img-src does not allow data:, either allow it or pass modalOptions.qrRenderer and draw the QR into the container the SDK provides (the universal URL to encode is prepared.data.data.url). With preventDefaultUI: true the SDK injects nothing at all and you render the QR from getDesktopQRImage(prepared.data).
  • The completion page helper takes the same option: getCompletionPageHtml('/api/glide/complete', { nonce }) stamps nonce="…" on its inline <script> and <style> and switches its own meta policy from 'unsafe-inline' to 'nonce-…'. Generate a fresh nonce per response and put the same value in your Content-Security-Policy header.
  • Keep endpoints.reportInvocation enabled. It is the fire-and-forget call that records every invocation and is the denominator of the Authentication Success Rate (ASR); disabling it does not speed up the flow, it only makes your success rate unmeasurable.

Browser bundle contract

The two files below and the global they set are part of the package's public API and follow semver: the paths, the global name and the wrapper behaviour change only in a major release. Minor and patch releases may change what is on the global (additively) and the bytes of the files, never how you load them.

Path Syntax Wrapper Global
dist/browser/web-client-sdk.min.js ES2020 UMD — AMD define() if define.amd is present, else CommonJS module.exports, else global GlideWebClientSDK (global branch only)
dist/browser/web-client-sdk.es5.min.js ES5 None — window.GlideWebClientSDK = …, unconditionally GlideWebClientSDK, always

On a page where define, require, module and exports are all undefined, both files set window.GlideWebClientSDK and window.GlideWebClientSDK.PhoneAuthClient is the client class. On a page that already has an AMD loader (typeof define === 'function' && define.amd), the ES5 file still sets the global and never calls define; the UMD file registers an anonymous module instead, which is why RequireJS pages must use the ES5 file. The ES5 file also ignores any module / exports globals the page happens to have. src/__tests__/bundle/browser-contract.test.ts asserts each of these against the freshly built bundles on every test run.

Integrity. npm run build writes a <file>.sha256 sidecar next to each browser bundle in sha256sum format (<hex> <filename>), and the sidecars ship in the npm tarball under dist/browser/, reachable through the ./dist/browser/* export. Verify at vendoring time:

const crypto = require('crypto');
const expected = fs.readFileSync(path.join(sdkDir, 'dist/browser/web-client-sdk.es5.min.js.sha256'), 'utf8').split(/\s+/)[0];
const actual = crypto.createHash('sha256').update(fs.readFileSync(path.join(sdkDir, 'dist/browser/web-client-sdk.es5.min.js'))).digest('hex');
if (actual !== expected) throw new Error(`glide-fe-sdk-web: bundle digest mismatch (${actual} != ${expected})`);

The sidecar inside the tarball is a convenience — it was produced by the same build as the file it describes. The independent digest for each release is published on hub.glideid.app and in the release notes; pin that one in your build if you need a check that does not trust the package contents.

Eligibility & Strategy Selection

Check Eligibility

Query available authentication strategies before starting a flow. The response is relative to the user's device: available_platforms names the phone the user needs, and requirements.target is self (this device) or companion (a separate phone, e.g. desktop QR/CTAP).

client_info.user_agent is required by the API; the web SDK auto-fills it from navigator.userAgent when you don't pass client_info, so a plain call works in the browser. Pass client_info explicitly to test a specific device.

const eligibility = await client.checkEligibility({
  phone_number: '+14155551234',
  // client_info optional in the browser — auto-filled from navigator.userAgent
});

// Example (desktop Chrome, T-Mobile):
// eligibility.available_options = [
//   { authentication_strategy: 'ts43', name: 'SIM-Based Authentication', available_platforms: ['android'],
//     requirements: { target: 'companion', android_min_version: 14, android_min_sdk: 34 } },
//   { authentication_strategy: 'desktop', name: 'Mobile Companion Authentication', available_platforms: ['android'],
//     requirements: { target: 'companion', android_min_version: 14, android_min_sdk: 34 } },
// ]

Strategy Override

Pass authentication_strategy in the prepare request to explicitly select a strategy from the eligible set:

const prepared = await client.prepare({
  use_case: USE_CASE.VERIFY_PHONE_NUMBER,
  phone_number: '+14155551234',
  authentication_strategy: 'link',  // override server-assigned strategy
});

If the requested strategy is not available, the API returns a STRATEGY_NOT_AVAILABLE error.

Advanced Features

Single active invoke (link / desktop)

Only one link- or desktop-strategy flow can be active in a browser tab at a time. If you call prepare() or invokeSecurePrompt() for a different session while a previous invoke is still pending, the SDK cancels the stale invoke (clears its listeners) instead of orphaning it. This matters when your backend uses prepareMultiple and the user picks a different number — the frontend should call prepare() (or invokeSecurePrompt() with a backend-prepared response) for the newly selected session without leaving the old flow running.

Link: which session completes

On the link strategy the App Clip is not guaranteed to come back for the latest link it was opened with. If a page runs prepare → invoke, nothing happens, and it runs prepare → invoke again, the App Clip can complete the first session. The SDK treats every link session the same PhoneAuthClient instance has invoked as its own: the pending invoke resolves with the key of whichever of them completed, and verifyPhoneNumber / getPhoneNumber (and therefore authenticate()) process against that session, so the flow finishes without you doing anything. Completions for sessions prepared by other tabs are ignored — the completion page's StorageEvent reaches every tab on the origin — and a tab whose sessions never complete rejects with TIMEOUT after 60 s. Keep one PhoneAuthClient per page (the React/Vue adapters already do) so retries share this memory.

Bring Your Own HTTP Client

The default HTTP client is intentionally minimal — it only handles JSON serialization, timeouts, abort signals, and error parsing. Anything else (custom headers, auth tokens, environment routing, retries, axios/ky/fetch wrappers, request/response interceptors) belongs in your own httpClient implementation:

const client = new PhoneAuthClient({
  endpoints: { ... },
  httpClient: {
    post: async (url, body, options) => {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${await getAuthToken()}`,
          'X-Trace-Id': crypto.randomUUID(),
          ...options?.headers,
        },
        body: JSON.stringify(body),
        credentials: options?.credentials,
        signal: options?.signal,
      });
      if (!response.ok) throw await response.json();
      return response.json();
    },
    get: async (url, options) => {
      const response = await fetch(url, {
        headers: {
          'Authorization': `Bearer ${await getAuthToken()}`,
          ...options?.headers,
        },
        credentials: options?.credentials,
        signal: options?.signal,
      });
      if (!response.ok) throw await response.json();
      return response.json();
    },
  },
});

The SDK calls httpClient.post() / httpClient.get() for every request — you control transport, headers, retries, and observability end-to-end.

Bring Your Own Logger

The SDK is silent by default — no console output of any level. Two ways to opt in:

  1. Built-in console logger — set debug: true to enable verbose output (debug / info / warn / error all routed through console.*, with phone numbers, JWTs, and session keys auto-redacted):

    const client = new PhoneAuthClient({ endpoints: { ... }, debug: true });
    
  2. Bring your own — forward SDK logs to your existing logging infrastructure (Sentry, Datadog, Pino, etc.) by providing a logger that satisfies the Logger interface. Your logger receives every level the SDK emits; level filtering is your responsibility:

    const client = new PhoneAuthClient({
      endpoints: { ... },
      logger: {
        debug: (msg, data) => myLogger.debug('[SDK]', msg, data),
        info:  (msg, data) => myLogger.info('[SDK]',  msg, data),
        warn:  (msg, data) => myLogger.warn('[SDK]',  msg, data),
        error: (msg, data) => myLogger.error('[SDK]', msg, data),
      },
    });
    

The SDK never logs caught errors before re-throwing — your try/catch sees them once. Routine flow events (prepare success, phone retrieved, desktop companion progress) are emitted at debug so they stay quiet unless you explicitly want them. warn is reserved for genuinely recoverable failures (modal failed to render, invocation report upload failed).

Private Browsing (iOS Safari)

iOS Safari Private Browsing keeps its own cookie jar, so a verification that starts in a private tab cannot complete when the carrier App Clip returns to a standard tab. prepare() / authenticate() stamp the browser estimate on client_info.privacy_mode; the server rejects private with UNSUPPORTED_PLATFORM and details.reason === 'private_browsing' (a normal backend error, propagated to desktop QR parents when applicable):

import { isAuthError } from '@glideidentity/glide-fe-sdk-web';

try {
  await client.authenticate({ use_case: 'VerifyPhoneNumber', phone_number });
} catch (error) {
  if (isAuthError(error) && error.code === 'UNSUPPORTED_PLATFORM' &&
      error.details?.reason === 'private_browsing') {
    // "Authentication cannot complete in Private Browsing. Please retry in a
    // regular browser tab."
    showNotice(error.message);
  }
}

estimatePrivateMode() is available if you want to check before the user taps Verify:

import { estimatePrivateMode } from '@glideidentity/glide-fe-sdk-web';

const { status, reason, opfs, quotaBytes } = await estimatePrivateMode();
if (status === 'private') {
  showNotice('For best results, verify outside Private Browsing.');
}
status Meaning
private A private tab (reason: 'ios_opfs', or 'ios_quota' from the fallback)
standard A standard tab or installed PWA, confirmed by the OPFS probe (reason: 'ios_opfs')
unknown Not iOS (reason: 'not_ios'), no signal was readable (reason: 'unavailable'), or the quota fallback saw a large quota that no longer proves a standard tab (reason: 'ios_quota_inconclusive')
How it decides

Private Browsing is byte-identical to a standard tab in the user agent, so the estimator reads two storage signals on iOS and reports which one decided:

  1. OPFS probe (reason: 'ios_opfs') — Safari ≥ 15.2 rejects navigator.storage.getDirectory() in a private window and resolves it in a standard one. This is authoritative and not threshold based. It needs a secure context; over plain HTTP the API is absent.
  2. Quota threshold (reason: 'ios_quota') — fallback for WebKit builds with no getDirectory. Up to iOS 18.3 a private tab was capped near 1 GB while a standard tab got far more. A quota below the threshold still reads as private; a quota at/above it reports unknown (reason: 'ios_quota_inconclusive') rather than standard, because iOS 18.4 normalised the two and a large quota no longer proves a standard tab. The fallback therefore never claims standard on its own.

opfs ('resolved' | 'rejected' | 'unavailable') and quotaBytes are always reported when read, even when the other signal decided, so a wrong verdict on a real device is diagnosable from one log line.

Notes:

  • estimatePrivateMode is advisory. It never throws. prepare() reports its status on client_info.privacy_mode; the server decides whether to reject. unknown and standard are never rejected.
  • Biased toward unknown. A false unknown is always preferred over a false private, and non-iOS browsers return unknown rather than a guess.
  • Logged. prepare() logs every estimate at debug through the configured Logger. Without that an unknown is indistinguishable from the probe never running.
  • Injectable. Pass { userAgent, estimateStorage, getDirectory } to test it or to run it outside a browser; with no arguments it reads navigator.

Core Package

For advanced use cases, you can import just the types and validators without the full client:

import { 
  // Types
  type PrepareRequest,
  type PrepareResponse,
  type InvokeResult,
  type SessionInfo,
  
  // Constants
  USE_CASE,
  AUTHENTICATION_STRATEGY,
  ERROR_CODES,
  
  // Validators
  validatePhoneNumber,
  validatePlmn,
  
  // Type Guards
  isTS43Strategy,
  isLinkStrategy,
  isDesktopStrategy,
  isAuthError,
} from '@glideidentity/glide-fe-sdk-web/core';

// Use validators
const { valid, error } = validatePhoneNumber('+14155551234');

// Use type guards
if (isDesktopStrategy(result)) {
  // TypeScript knows result has desktop-specific properties
}

Error Handling

import { ERROR_CODES, isAuthError } from '@glideidentity/glide-fe-sdk-web';

try {
  await client.authenticate({ ... });
} catch (error) {
  if (isAuthError(error)) {
    switch (error.code) {
      case ERROR_CODES.USER_CANCELLED:
        // User closed the modal or cancelled
        break;
      case ERROR_CODES.TIMEOUT:
        // Authentication timed out
        break;
      case ERROR_CODES.NETWORK_ERROR:
        // Network request failed
        break;
      default:
        // Handle other errors
        console.error(error.message);
    }
  }
}

Type Reference

Use Cases

type UseCase = 'GetPhoneNumber' | 'VerifyPhoneNumber';

// Or use the constant
USE_CASE.GET_PHONE_NUMBER    // 'GetPhoneNumber'
USE_CASE.VERIFY_PHONE_NUMBER // 'VerifyPhoneNumber'

Prepare Request

interface PrepareRequest {
  use_case?: UseCase;         // Optional when options.parent_session_id is provided
  phone_number?: string;      // Required for VerifyPhoneNumber
  options?: PrepareOptions;
}

interface PrepareOptions {
  parent_session_id?: string;                       // For cross-device (desktop↔mobile) binding
  theme?: string;                                   // UI theme, free-form (max 32 chars), default "dark"
  mobile_companion_passthrough?: Record<string, unknown>; // Opaque JSON for the branded companion app
  sim_swap_max_age_hours?: number;                  // SIM swap query window; integer in 1–2400 hours
  biometric?: boolean;                              // Desktop (QR): parent prepare; TS43: device biometric step
}

Invoke Result

interface InvokeResult {
  strategy: 'ts43' | 'link' | 'desktop';
  session: SessionInfo;
  credential: Promise<string>;  // Resolves to credential token
  cancel?: () => void;          // Available for link and desktop
  invocationReport: Promise<{ success: boolean; error?: string }>;
}

ASR Tracking (reportInvocation)

The SDK automatically tracks authentication attempts for Authentication Success Rate (ASR) metrics. This is handled via the reportInvocation endpoint.

Behavior

Aspect Description
Fire-and-forget Never blocks the main authentication flow
Non-critical Auth succeeds even if reporting fails
Observable Developers CAN check if it worked (optional)
No latency impact User doesn't wait for this API call

Configuration

The reportInvocation endpoint is included in the default config:

const client = new PhoneAuthClient({
  endpoints: {
    prepare: '/api/magical-auth/prepare',
    process: '/api/magical-auth/process',
    reportInvocation: '/api/magical-auth/report-invocation',  // ASR tracking (default)
  }
});

Monitoring (Optional)

If you want to monitor whether ASR tracking succeeded:

const result = await client.invokeSecurePrompt(prepared);

// Main flow continues immediately
const credential = await result.credential;

// Optional: Check report status (doesn't block)
result.invocationReport?.then(({ success, error }) => {
  if (!success) {
    console.warn('ASR tracking failed:', error);
    // Optionally send to your own analytics
  }
});

Backend Endpoint

Your backend should implement a simple pass-through endpoint:

// POST /api/magical-auth/report-invocation
app.post('/api/magical-auth/report-invocation', async (req, res) => {
  const { session_id } = req.body;
  
  try {
    const result = await glide.magicalAuth.reportInvocation({ session_id });
    res.json({ success: result.success });
  } catch (error) {
    // Always return HTTP 200 - never fail the auth flow
    res.json({ success: false, error: error.message });
  }
});

Responses

interface NetworkCheck {
  match: 'same_ip' | 'same_subnet' | 'different' | 'unknown' | (string & {});
  family?: 'ipv4' | 'ipv6' | (string & {});
  prefix_bits?: number;       // Matching leading bits of the two egress IPs
  desktop_ip?: string;
  companion_ip?: string;
}

interface GetPhoneNumberResponse {
  phone_number: string;
  nonce?: string;             // The session's prepare nonce
  aud?: string;               // Audience from carrier
  sim_swap?: SimSwapInfo;     // SIM swap detection info
  device_swap?: DeviceSwapInfo; // Device swap (IMEI change) detection info
  biometric_verification?: BiometricVerification | (string & {});
  network_check?: NetworkCheck; // Desktop QR same-network signal, when opted in
  session_key?: string;
  request_id?: string;        // Quote to Glide support
}

interface VerifyPhoneNumberResponse {
  phone_number: string;
  verified: boolean;
  nonce?: string;             // The session's prepare nonce
  aud?: string;               // Audience from carrier
  sim_swap?: SimSwapInfo;     // SIM swap detection info
  device_swap?: DeviceSwapInfo; // Device swap (IMEI change) detection info
  biometric_verification?: BiometricVerification | (string & {});
  network_check?: NetworkCheck; // Desktop QR same-network signal, when opted in
  verification_record?: string; // Signed JWS; verify on your backend
  verification_record_status?: 'signed' | 'unavailable' | (string & {});
  session_key?: string;
  request_id?: string;        // Quote to Glide support
}

Biometric Verification

Set options.biometric on prepare to ask the device to run a biometric step and report the outcome. On Desktop (QR), set it on the parent prepare; the phone inherits it. On TS43 it runs during the credential ceremony. Explicit false is sent as false; unset is omitted. The outcome comes back as biometric_verification on the getPhoneNumber / verifyPhoneNumber success response. The server sends it on every success, but the response reaches the SDK through your backend, so the field is typed optional. On verify it equals the claim in the signed verification record.

Value Meaning
VERIFIED The user passed the device biometric step.
FAILED The device attempted it and it failed.
NOT_ENROLLED The device has no lock-screen credential. The user can fix this by setting one, then retrying.
UNSUPPORTED The device or flow could not perform the step.
NOT_REQUESTED You did not ask for a biometric step.

Treat anything other than VERIFIED as not verified, including a missing field or a value not in this table (it arrives as the raw string, so the type is an open union):

import { BIOMETRIC_VERIFICATION } from '@glideidentity/glide-fe-sdk-web';

const result = await client.verifyPhoneNumber(invoke.credential, invoke.session);
if (result.biometric_verification === BIOMETRIC_VERIFICATION.VERIFIED) {
  // biometric passed
} else if (result.biometric_verification === BIOMETRIC_VERIFICATION.NOT_ENROLLED) {
  // not verified; prompt the user to set a lock-screen credential
} else {
  // not verified
}

If you only know whether a biometric step is needed after prepare on TS43 (for example, prepare runs on your backend before the page renders), request it at invoke instead:

const invoke = await client.invokeSecurePrompt(prepared, { biometric: true });

On TS43 this appends Google's user_verification_hint DCQL claim to the credential request before the browser prompt, with no extra backend call. InvokeOptions.biometric applies to TS43 only; on a Desktop (QR) result it has no effect — request biometric on the desktop parent at prepare (options.biometric).

Requesting a biometric step never makes the biometric step mandatory. A device that cannot perform it still authenticates, and the phone number is verified either way, so use this to decide whether to step up — never as the authentication result itself.

SIM Swap Detection

The SDK returns SIM swap detection information when available from carriers. This helps identify potential fraud.

The query window

A swap check always answers a question of the form "was there a swap in the last N hours?", and max_age_hours on the response tells you which N produced the answer. Set it per session with options.sim_swap_max_age_hours on prepare — an integer in 1–2400 hours; a value outside that range is refused with 400 VALIDATION_ERROR before a session is created. Omit it and the server falls back to the carrier's configured default and then to 24 hours.

Neither swapped nor risk_level should be read as "no swap happened", but for different reasons:

  • swapped is omitted when the carrier gave no determinable age (T-Mobile codes 999 / NA). The check was inconclusive; absent is not false.
  • risk_level is RISK_LEVEL_UNKNOWN in two different situations. One is that same inconclusive check. The other is a CAMARA carrier confirming no swap inside the window — a successful check, returning swapped: false and age_band: "older than query window". It still grades as UNKNOWN because the carrier never dated the last swap, so no risk band can be assigned. This case used to report RISK_LEVEL_LOW, so code that read LOW as "safe" now falls through to your default policy.

The practical rule: branch on swapped for "was there a swap in my window", and treat RISK_LEVEL_UNKNOWN as "no risk grade available" rather than as either a good or a bad outcome.

A known age_band together with swapped: false is not a contradiction: it means the carrier dated the last swap and it fell outside the window. The flag is the band overlapped with the window, and a band straddling the boundary resolves to swapped rather than to an unknown — so with a 24-hour window a "14-30 days" band gives swapped: false alongside RISK_LEVEL_MEDIUM, because risk comes from the band's own recency, not from the window.

Response Fields

interface SimSwapInfo {
  /** Whether the SIM swap check completed successfully */
  checked: boolean;
  /** Risk level based on SIM change recency */
  risk_level?: 'RISK_LEVEL_HIGH' | 'RISK_LEVEL_MEDIUM' | 'RISK_LEVEL_LOW' | 'RISK_LEVEL_UNKNOWN';
  /** Human-readable time since last SIM change (e.g., "0-4 hours", "more than 3 years") */
  age_band?: string;
  /** Carrier that performed the check */
  carrier_name?: string;
  /** When the check was performed (RFC3339) */
  checked_at?: string;
  /** Whether a swap occurred within the query window (omitted when unknown) */
  swapped?: boolean;
  /** Query window in hours used for the check */
  max_age_hours?: number;
  /** Reason for failure if checked=false */
  reason?: 'timeout' | 'carrier_not_supported' | 'disabled' | 'error';
}

Risk Levels

Level Description Recommended Action
RISK_LEVEL_HIGH SIM changed within 7 days Block or require additional verification
RISK_LEVEL_MEDIUM SIM changed 7-30 days ago Consider step-up authentication
RISK_LEVEL_LOW SIM changed 30+ days ago Normal processing
RISK_LEVEL_UNKNOWN Could not determine Use default policy

Helper Functions

import { 
  isHighRiskSimSwap,
  isMediumOrHighRiskSimSwap,
  wasSimSwapChecked,
  getMinDaysSinceSimSwap,
} from '@glideidentity/glide-fe-sdk-web';

const result = await client.getPhoneNumber(credential, session);

// Check if SIM swap was checked
if (wasSimSwapChecked(result.sim_swap)) {
  // Check risk level
  if (isHighRiskSimSwap(result.sim_swap)) {
    // High risk - recent SIM change
    requireAdditionalVerification();
  } else if (isMediumOrHighRiskSimSwap(result.sim_swap)) {
    // Medium risk - consider step-up auth
    showWarning();
  }
  
  // Get minimum days since SIM change
  const minDays = getMinDaysSinceSimSwap(result.sim_swap);
  console.log(`SIM unchanged for at least ${minDays} days`);
}

Device Swap Detection

The SDK returns device swap (IMEI change) detection information when available from carriers. This is a parallel, independent signal alongside SIM swap detection. Not all carriers support this signal — the device_swap field is only present when the carrier provides it.

It shares the SIM swap query window: options.sim_swap_max_age_hours sets both, and max_age_hours on this payload reports the same window. The swapped and risk_level caveats above apply here too. reason is typed for symmetry with SimSwapInfo, but the server does not populate a failure reason for device swap, so expect it to be absent.

Response Fields

interface DeviceSwapInfo {
  /** Whether the device swap check completed successfully */
  checked: boolean;
  /** Risk level based on device change recency */
  risk_level?: 'RISK_LEVEL_HIGH' | 'RISK_LEVEL_MEDIUM' | 'RISK_LEVEL_LOW' | 'RISK_LEVEL_UNKNOWN';
  /** Human-readable time since last device (IMEI) change (e.g., "0-4 hours", "more than 3 years") */
  age_band?: string;
  /** Carrier that performed the check */
  carrier_name?: string;
  /** When the check was performed (RFC3339) */
  checked_at?: string;
  /** Whether a swap occurred within the query window (omitted when unknown) */
  swapped?: boolean;
  /** Query window in hours used for the check */
  max_age_hours?: number;
  // No failure reason: the server does not send one. Branch on `checked`.
}

Risk Levels

Level Description Recommended Action
RISK_LEVEL_HIGH Device changed within 7 days Block or require additional verification
RISK_LEVEL_MEDIUM Device changed 7-30 days ago Consider step-up authentication
RISK_LEVEL_LOW Device changed 30+ days ago Normal processing
RISK_LEVEL_UNKNOWN Could not determine Use default policy

Helper Functions

import { 
  isHighRiskDeviceSwap,
  isMediumOrHighRiskDeviceSwap,
  wasDeviceSwapChecked,
  getMinDaysSinceDeviceSwap,
} from '@glideidentity/glide-fe-sdk-web';

const result = await client.getPhoneNumber(credential, session);

// Check if device swap was checked
if (wasDeviceSwapChecked(result)) {
  // Check risk level
  if (isHighRiskDeviceSwap(result)) {
    // High risk - recent device change
    requireAdditionalVerification();
  } else if (isMediumOrHighRiskDeviceSwap(result)) {
    // Medium risk - consider step-up auth
    showWarning();
  }
  
  // Get minimum days since device change
  const minDays = getMinDaysSinceDeviceSwap(result);
  console.log(`Device unchanged for at least ${minDays} days`);
}

Browser Support

Browser Eligible strategies Requirements
Chrome Android 128+ ts43 (when the carrier supports TS43) or link (mobile-web mode, when the carrier uses the App Clip / App Link flow) Digital Credentials API
Safari iOS / Chrome iOS link (mobile-web mode) App Clip support on the device
Chrome / Edge / Firefox Desktop desktop Any modern version

Strategy assignment is done by Glide's Magical Auth service in prepare (using the carrier + your user agent) — the same browser may receive different strategies for different carriers, and link runs in mobile-web mode on every supported mobile browser.

Development (contributors)

npm ci
npm test
npm run typecheck
npm run build   # CJS, ESM, types, both browser bundles, then check:es5 and build:sha256
npm run check:es5   # acorn (ES5) + uglify-js 1.2.5 gate on dist/browser/web-client-sdk.es5.min.js
npm run build:sha256   # <file>.sha256 sidecars (sha256sum format) next to both browser bundles
npm run clean
npm pack        # for local install testing

Support

License

Apache License 2.0 — see LICENSE in this package.

Related Packages

iflow-engine-base

BIM Engine SDK for Vue2, Vue3, React and HTML

29,473
iflow-engine-base-3d

BIM Engine SDK for Vue2, Vue3, React and HTML

9,510
iflow-engine-base-test

BIM Engine SDK for Vue2, Vue3, React and HTML

369
paymid-sdk

A frontend SDK for Paymid payment processing

15,682
@yukino.js/sentry

Framework-agnostic sentry sdk with first-class support for react and vue3+

195 1

Version History

Version Released Vue License
3.2.0 ^3.0.0 Apache-2.0
3.1.0 ^3.0.0 Apache-2.0
3.0.0 ^3.0.0 Custom
2.3.1 ^3.0.0 Custom
2.3.0 ^3.0.0 Custom
Pre-releases (1)
Version Released Vue License
3.1.0-rc.1 ^3.0.0 Apache-2.0