Device-Bound Authentication for Link Protocol
Device binding is mandatory for all Link Protocol sessions (e.g., Verizon via App Clips). TS43 and Desktop (QR) protocols are not affected. There is no opt-out.
The device binding mechanism is primarily a backend responsibility. Your backend generates the cryptographic codes, sets the secure cookie, and calls the /complete endpoint. The frontend's role is limited to extracting the agg_code from the URL fragment and forwarding it to your backend.
Overview
Device-Bound Authentication is a security mechanism for the Link Protocol (OAuth-based carrier authentication) that cryptographically binds the verification result to the device that initiated the authentication request. It prevents a class of session fixation attacks where an attacker could phish a victim into authenticating on the attacker's session.
This feature is mandatory for all Link Protocol sessions. There is no opt-out or backward-compatibility mode. If fe_hash is missing from a Link Protocol /prepare request, the request is rejected with HTTP 400.
Applies to: Link Protocol sessions only. TS43 and Desktop (QR) protocols are not affected.
The Problem: Session Fixation via Phishing
Without device binding, the Link Protocol flow has no cryptographic link between the device that started the flow and the device that authenticated. This creates a phishing vulnerability:
Attack Scenario:
1. Attacker calls /prepare on their service (e.g., coinbase.com)
→ receives session_key + App Clip URL
2. Attacker sends the App Clip URL to the victim via SMS, email, or social engineering
3. Victim opens the App Clip URL on their phone and authenticates with their carrier
→ Carrier verifies the victim's phone number
→ Session transitions to "completed" with the victim's phone number
4. Attacker polls GET /public/status/{session_key}
→ Sees status: "completed"
5. Attacker calls POST /verify-phone-number with the session_key
→ Receives the victim's verified phone number
Result: The attacker has stolen the victim's verified phone identity.
The core issue: nothing proves the same device that started the session is the one that completed authentication.
The Solution: Dual-Code Binding + Developer Ownership
Device binding introduces three independent security layers that each prove a different aspect of session integrity:
fe_code (Frontend Code)
What it proves: The same browser that initiated the authentication is the one completing it.
- Generated by the developer's backend (BE SDK) as a cryptographically random value
- Stored as a
SHA256hash (fe_hash) on the Glide session during/prepare - Delivered to the user's browser as an HttpOnly, Secure, SameSite=Lax cookie
- The raw
fe_codeis never stored server-side — only the hash - At
/completetime, the backend sendsfe_codeand Glide recomputesSHA256(fe_code)to verify it matchesfe_hash - At
/processtime (/verify-phone-numberor/get-phone-number),fe_codeis also validated — full chain: prepare → complete → process
Security properties:
- HttpOnly → immune to XSS (JavaScript cannot read it)
- Secure → only sent over HTTPS
- SameSite=Lax → sent on top-level navigations (required for the redirect flow) but not on cross-site subrequests
- Cookie → automatically attached by the browser to same-origin requests (no client-side code needed)
agg_code (Aggregator Code)
What it proves: The same device received the carrier's OAuth redirect.
- Generated by Glide's aggregator during the OAuth callback (after carrier verification succeeds)
- Delivered to the developer's registered redirect URL as a URL fragment (after
#) - URL fragments are never sent to any server in HTTP requests — they exist only in the browser
- At
/completetime, the backend sendsagg_codeand Glide recomputesSHA256(agg_code)to verify it matchesagg_hash
Security properties:
- URL fragment → never appears in HTTP requests, server logs, or Referer headers
- Only accessible by client-side JavaScript on the redirect page
- One-time use — the session is finalized on first valid
/completecall
Developer ID Ownership Check
What it proves: The API caller who is retrieving results is the same developer who created the session.
- Every authenticated endpoint (
/prepare,/complete,/verify-phone-number,/get-phone-number) validates that the requesting developer's ID (from Apigee authentication) matches the developer who created the session - Prevents cross-developer session theft — developer A cannot use a session created by developer B
- Enforced at the session retrieval layer (
GetSessionForDeveloper), so all endpoints are protected automatically
Naming Convention
Each binding has two parts with consistent naming:
| Binding | Code (private preimage, sent in /complete) | Hash (public SHA256, stored on session) |
|---|---|---|
| FE | fe_code / FeCode | fe_hash / FeHash |
| Agg | agg_code / AggCode | agg_hash / AggHash |
- code: The random value (preimage). Called "code" because it is transmitted, not kept secret forever.
- hash: The SHA256 digest of the code, committed at prepare/callback time. Self-documenting — it's literally the hash.
Complete Authentication Flow
Phase 1: Prepare with Device Binding
User visits developer's website (e.g., coinbase.com)
↓
Developer's Frontend SDK triggers authentication
↓
Developer's Backend SDK:
1. Generates fe_code (32 random bytes, hex encoded = 64 chars)
2. Computes fe_hash = SHA256(fe_code) (64 hex chars)
3. Calls POST /magic-auth/v2/auth/prepare with:
- fe_hash in the request body
- Standard fields (nonce, use_case, phone_number, plmn, client_info)
↓
Glide Aggregator:
1. Validates fe_hash is a valid 64-char hex string (SHA256 format)
2. Validates developer has a registered Completion Redirect URL
3. Creates session with status: "pending"
4. Stores fe_hash + CompletionRedirectURL on the session
5. Returns session_key + App Clip URL (or OAuth URL)
↓
Developer's Backend SDK:
1. Returns the session response to the frontend
2. Sets an HttpOnly cookie: _glide_bind_{session_key_prefix}=<fe_code>
(Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=300)
Phase 2: App Clip / OAuth Authentication
Developer's Frontend SDK:
1. Opens the App Clip URL (iOS) or OAuth URL in a new tab/popup
↓
User authenticates with their carrier (automatic via App Clip)
↓
Carrier redirects back to Glide's OAuth callback endpoint
↓
Glide Aggregator (OAuth callback):
1. Exchanges the authorization code with the carrier
2. Verifies the phone number (or retrieves it)
3. Sets CarrierVerified = true (carrier's actual result)
4. Sets Verified = false (NOT yet verified — awaiting /complete)
5. Generates agg_code (32 random bytes, hex encoded = 64 chars)
6. Computes agg_hash = SHA256(agg_code)
7. Stores agg_hash on session
8. Sets session status = "pending_completion"
9. Stores phone number on session (encrypted in Redis)
10. Redirects (HTTP 302) to:
{CompletionRedirectURL}#agg_code={agg_code}&session_key={session_key}
Phase 3: Complete with Dual Validation
Developer's Completion Redirect Page (served by getCompletionPageHtml() helper):
1. Extracts agg_code and session_key from URL fragment (after #)
2. Cleans the fragment from the URL via history.replaceState
3. Sends both codes to the developer's backend
↓
Developer's Backend:
1. Reads fe_code from the HttpOnly cookie using parseBindingCookie()
2. Calls POST /magic-auth/v2/auth/complete with:
- session_key
- fe_code (from cookie)
- agg_code (from frontend)
↓
Glide Aggregator (/complete):
1. Validates developer owns this session (GetSessionForDeveloper)
2. Verifies session status == "pending_completion"
3. Verifies session protocol == "link"
4. Validates both codes (constant-time comparison):
- SHA256(fe_code) == session.FeHash ✓
- SHA256(agg_code) == session.AggHash ✓
5. On success:
- Copies CarrierVerified → Verified
- Sets status = "completed"
- Returns { "status": "completed" } (HTTP 204 in SDKs)
6. On failure:
- Returns HTTP 403 "Device binding validation failed"
- Does NOT reveal which code failed
↓
Completion Page:
1. Writes localStorage signal: glide_signal_{session_key} = session_key
2. Closes the tab immediately (auto-cleanup after 5 seconds)
↓
Original Tab (FE SDK):
1. Receives StorageEvent for glide_signal_* key
2. If signal session differs from prepared session → session swap (iOS App Clip buffering)
↓
Developer's Backend:
1. Reads fe_code from cookie using parseBindingCookie(cookieHeader, sessionKey)
2. Calls POST /verify-phone-number (or /get-phone-number) with fe_code
3. Glide validates fe_code AGAIN at process step (full chain validation)
4. Retrieves the verified phone number + anti-fraud signals (sim_swap, device_swap)
Flow Diagram
Session Lifecycle and Status Transitions
Device-bound sessions follow a strict, one-way status progression:
┌─────────┐ /prepare ┌──────────┐ OAuth callback ┌─────────────────────┐
│ (none) │ ──────────────→ │ pending │ ────────────────→ │ pending_completion │
└─────────┘ └──────────┘ └─────────────────────┘
│ │
│ (error/timeout) │
▼ │
┌────────┐ │
│ failed │ ◄─────────────────────────────────┤ (error/cleanup)
└────────┘ │
│ POST /complete
│ (both codes valid)
▼
┌───────────┐
│ completed │
└───────────┘
Key rules:
pending_completion→completedcan ONLY happen via/complete(not via/update-session)pending_completion→failedis allowed (cleanup/error handling)completedis terminal — no further transitionsfailedis terminal — no resurrection allowed (preventspending_completion → failed → completedbypass)/public/status/{session_key}returns only the status forpending_completionsessions (no credentials, no phone number)
API Reference
POST /magic-auth/v2/auth/prepare
Initiates a new authentication session with device binding.
Authentication: Required (OAuth 2.0 Client Credentials — Bearer token)
Headers:
| Header | Required | Description |
|---|---|---|
Authorization | Yes | Bearer <access_token> obtained via OAuth 2.0 Client Credentials |
Developer-Completion-Redirect-Url | Yes (for link protocol) | Registered HTTPS URL for redirect after carrier auth |
Request Body:
{
"nonce": "unique-request-nonce",
"use_case": "VerifyPhoneNumber",
"phone_number": "+15551234567",
"plmn": {
"mcc": "310",
"mnc": "260"
},
"client_info": {
"user_agent": "Mozilla/5.0 ...",
"platform": "iPhone"
},
"fe_hash": "a1b2c3d4e5f6...64_hex_chars...of_sha256_hash"
}
| Field | Type | Required | Description |
|---|---|---|---|
nonce | string | Yes | Unique request identifier |
use_case | string | Yes | "VerifyPhoneNumber" or "GetPhoneNumber" |
phone_number | string | Conditional | E.164 format. Required for VerifyPhoneNumber |
plmn | object | No | Network identifiers (MCC/MNC) |
client_info | object | No | Browser/device information for strategy selection |
fe_hash | string | Yes (link protocol) | SHA256 hash of fe_code, hex encoded (64 characters). Mandatory for all link protocol sessions |
options | object | No | Advanced options (parent_session_id, theme) |
Success Response (200):
{
"authentication_strategy": "link",
"session": {
"session_key": "a1b2c3d4e5f6g7h8",
"nonce": "unique-request-nonce",
"protocol_type": "link"
},
"data": {
"protocol": "link",
"data": {
"url": "https://carrier.com/oauth/authorize?..."
}
}
}
Error Responses:
| Status | Condition |
|---|---|
| 400 | Missing fe_hash for link protocol |
| 400 | Invalid fe_hash format (not 64 hex chars) |
| 400 | Missing Developer-Completion-Redirect-Url header |
GET /public/status/{session_key}
Polls for session status. This is a public (unauthenticated) endpoint.
Response for pending_completion sessions:
{
"session_key": "a1b2c3d4e5f6g7h8",
"status": "pending_completion",
"protocol": "link",
"created_at": "2026-02-15T10:30:00Z",
"last_updated": "2026-02-15T10:30:45Z"
}
Important: When the session is in
pending_completionstatus, the response deliberately omits all credentials, phone numbers, and extra data. Only after/completesucceeds (transitioning tocompleted) will the full result be available through/verify-phone-numberor/get-phone-number.
POST /magic-auth/v2/auth/complete
Validates both device binding codes and finalizes the session.
Authentication: Required (Apigee API key)
Request Body:
{
"session_key": "a1b2c3d4e5f6g7h8",
"fe_code": "raw_fe_code_64_hex_chars",
"agg_code": "raw_agg_code_64_hex_chars"
}
| Field | Type | Required | Description |
|---|---|---|---|
session_key | string | Yes | Session identifier from /prepare |
fe_code | string | Yes | Raw frontend code (the preimage, not the hash) |
agg_code | string | Yes | Raw aggregator code (from URL fragment after redirect) |
Success Response (200):
{
"status": "completed"
}
Error Responses:
| Status | Condition | Body |
|---|---|---|
| 400 | Non-link protocol session | { "code": "BAD_REQUEST", "message": "Device binding completion is only supported for link protocol sessions", "status": 400 } |
| 400 | Missing required fields | { "code": "VALIDATION_ERROR", "message": "Request validation failed", "status": 400, "details": { "fields": { "fe_code": "required" } } } |
| 403 | Code mismatch (either or both) | { "code": "FORBIDDEN", "message": "Device binding validation failed", "status": 403 } |
| 404 | Session not found or wrong developer | { "code": "SESSION_NOT_FOUND", "message": "Session not found or expired", "status": 404 } |
| 409 | Session not in pending_completion | { "code": "SESSION_NOT_ELIGIBLE", "message": "Session is not eligible for completion", "status": 409 } |
| 500 | Infrastructure error (safe to retry) | { "code": "INTERNAL_SERVER_ERROR", "message": "An internal error occurred", "status": 500 } |
Security note: On code mismatch, the error message is deliberately generic (HTTP 403). It does not reveal which code failed. This prevents attackers from iterating on one code at a time.
POST /magic-auth/v2/auth/verify-phone-number
Retrieves the verification result after device binding is completed.
Authentication: Required (Apigee API key)
Prerequisite: The session must be in
completedstatus (i.e.,/completemust have succeeded). Sessions inpending_completionstatus are explicitly rejected by this endpoint.
Device Binding at Process Step (SEC-521):
For Link protocol sessions with device binding, fe_code is also validated at this endpoint — not just at /complete. The developer's backend must read fe_code from the cookie and include it in the request body. Glide recomputes SHA256(fe_code) and verifies it against the stored fe_hash.
This provides full-chain validation: prepare → complete → process.
| Field | Type | Required | Description |
|---|---|---|---|
fe_code | string | Conditional | Required for Link protocol sessions with device binding. The raw fe_code from the HttpOnly cookie. |
Response includes anti-fraud signals:
| Field | Type | Description |
|---|---|---|
sim_swap | object | SIM swap detection result (risk_level, age_band, carrier_name, checked_at) |
device_swap | object | Device swap / IMEI change detection (same structure as sim_swap) |
POST /magic-auth/v2/auth/get-phone-number
Retrieves the phone number after device binding is completed.
Authentication: Required (Apigee API key)
Prerequisite: Same as
/verify-phone-number— session must becompleted.
Device Binding: Same as /verify-phone-number — fe_code is required and validated for Link protocol sessions.
Response includes anti-fraud signals: Same sim_swap and device_swap fields as /verify-phone-number.
SDK Integration Guide
If you are using Glide's official SDKs, most of the device binding complexity is handled automatically.
Minimum SDK Versions
| SDK | Version | Package |
|---|---|---|
| FE SDK Web | v2.1.0+ | @glideidentity/glide-fe-sdk-web |
| BE SDK Node | v2.1.0+ | @glideidentity/glide-be-sdk-node + @glideidentity/glide-be-sdk-node-core |
| BE SDK Java | v5.1.0+ | com.glideidentity:glide-be-sdk-java (Maven Central, Java 11+ bytecode target) |
Backend SDK (BE SDK)
The Backend SDK handles:
- Generating
fe_code— viagenerateFeCode()(32 random bytes, hex encoded) - Computing
fe_hash— viacomputeFeHash()(SHA256 offe_code, hex encoded) - Including
fe_hashin/prepare— Automatically added to the request body - Building the HttpOnly cookie — via
buildSetBindingCookieHeader(feCode, sessionKey)(session-scoped name_glide_bind_{prefix}) - Serving the completion page — via
getCompletionPageHtml(completeEndpoint)(includes localStorage signal, CSP, error UI) - Reading
fe_codefrom cookie — viaparseBindingCookie(cookieHeader, sessionKey)(extracts from session-scoped cookie) - Calling
/complete— SDK'scomplete()method (returns void/204) - Passing
fe_codeto/process—fe_codeincluded inverifyPhoneNumber()andgetPhoneNumber()requests
Backend SDK Exported Helpers:
| Helper | Node SDK | Java SDK | Purpose |
|---|---|---|---|
generateFeCode() | ✅ | ✅ MagicalAuth.generateFeCode() | Generate 32-byte random hex |
computeFeHash() | ✅ | ✅ MagicalAuth.computeFeHash() | SHA256 hash of fe_code |
buildSetBindingCookieHeader() | ✅ | ✅ | Build Set-Cookie header string |
buildClearBindingCookieHeader() | ✅ | ✅ | Build cookie-clearing header |
parseBindingCookie() | ✅ | ✅ | Extract fe_code from cookie header |
getBindingCookieName() | ✅ | ✅ | Get session-scoped cookie name |
getCompletionPageHtml() | ✅ | ✅ | Generate completion page HTML |
complete() | ✅ (void/204) | ✅ (void/204) | Call /complete endpoint |
Backend SDK Usage (Node.js):
import {
generateFeCode, computeFeHash,
buildSetBindingCookieHeader, parseBindingCookie,
getCompletionPageHtml, getBindingCookieName
} from '@glideidentity/glide-be-sdk-node';
// 1. During /prepare
const prepareResponse = await glide.magicalAuth.prepare(requestBody);
// SDK internally generates fe_code, computes fe_hash, sends fe_hash to Glide.
// feCode only returned when authentication_strategy === 'link'
const { feCode, ...clientResponse } = prepareResponse;
if (feCode) {
// Set session-scoped HttpOnly cookie
const cookieHeader = buildSetBindingCookieHeader(feCode, prepareResponse.session.session_key);
res.setHeader('Set-Cookie', cookieHeader);
}
res.json(clientResponse); // feCode NEVER sent to client in response body
// 2. Serve completion page at GET /glide-complete
const html = getCompletionPageHtml('/api/phone-auth/complete');
res.setHeader('Content-Type', 'text/html');
res.send(html);
// 3. During POST /api/phone-auth/complete
const feCode = parseBindingCookie(req.headers.cookie, req.body.session_key);
await glide.magicalAuth.complete({
session_key: req.body.session_key,
fe_code: feCode,
agg_code: req.body.agg_code,
});
// Returns void (204) — do NOT clear cookie yet (needed for /process)
// 4. During POST /api/phone-auth/process (fe_code validated AGAIN)
const feCode = parseBindingCookie(req.headers.cookie, session.session_key);
const result = await glide.magicalAuth.verifyPhoneNumber({
session, credential, fe_code: feCode,
});
// result includes: verified, phone_number, sim_swap, device_swap
Frontend SDK (FE SDK)
The Frontend SDK handles:
- Sending
credentials: 'include'on bothprepare()andprocess()fetch calls (enables cookie passthrough) - Opening the App Clip URL via
window.location.href - Listening for StorageEvent —
handleLink()listens for anyglide_signal_*event (not filtered by current session) - Session swap — If the signal's session key differs from the prepared session,
authenticate()swaps the session key (handles iOS App Clip buffering) - Calling
/processwithcredentials: 'include'so the cookie is attached
The FE SDK sends credentials: 'include' on fetch calls. If your API endpoints are cross-origin, your backend must respond with:
Access-Control-Allow-Credentials: trueAccess-Control-Allow-Origin: https://your-domain.com(NOT*)
Completion Page Helper
Both the Node and Java SDKs export getCompletionPageHtml(completeEndpoint) which generates a secure, minimal HTML page that:
- Reads
agg_codeandsession_keyfrom the URL fragment - Cleans the fragment via
history.replaceState(defense-in-depth) - POSTs to your
/completeendpoint withcredentials: 'include' - Writes a
localStoragesignal (glide_signal_{session_key}) for cross-tab notification - Closes the tab automatically after success
- Shows professional error UI with per-error animated illustrations (no technical details shown to users)
- Includes a
Content-Security-Policymeta tag
Register this page at your completion redirect URL (e.g., GET /glide-complete).
End-to-End SDK Flow
1. FE SDK calls BE SDK → prepare() with credentials: 'include'
2. BE SDK generates fe_code, computes fe_hash (automatic)
3. BE SDK calls POST /prepare with fe_hash → gets session
4. BE SDK sets session-scoped cookie _glide_bind_{prefix}=fe_code
5. BE SDK returns session to FE SDK (feCode stripped from response body)
6. FE SDK opens App Clip URL
7. User authenticates with carrier via App Clip
8. Carrier redirects → Glide callback → Glide redirects to CompletionRedirectURL#agg_code=...
9. Completion page (getCompletionPageHtml) extracts agg_code from fragment
10. Completion page POSTs agg_code + session_key to developer's /complete endpoint
11. Developer backend reads fe_code from cookie via parseBindingCookie()
12. Developer backend calls SDK complete() with fe_code + agg_code → 204
13. Completion page writes localStorage signal glide_signal_{session_key}
14. Completion page closes tab
15. Original tab FE SDK receives StorageEvent → session swap if needed
16. FE SDK calls /process with credentials: 'include' (cookie attached)
17. Developer backend reads fe_code from cookie via parseBindingCookie()
18. Developer backend calls SDK verifyPhoneNumber() with fe_code → fe_code validated AGAIN
19. Result returned: verified, phone_number, sim_swap, device_swap
Self-Implementation Guide (Without SDK)
If you are integrating directly with the Glide API without using the official SDKs, you must implement the device binding protocol yourself. This section provides a complete, step-by-step guide.
Prerequisites
Before starting, ensure you have:
- A Glide developer account with an API key configured in Apigee
- A registered Completion Redirect URL — This HTTPS URL must be:
- Registered with Glide (configured in Apigee as the
Developer-Completion-Redirect-Urlheader value) - A page you control that can execute JavaScript
- Served over HTTPS (HTTP is rejected)
- Must not contain a URL fragment (
#) - Must not contain embedded credentials
- Registered with Glide (configured in Apigee as the
- A backend server capable of:
- Making HTTPS API calls to Glide
- Setting HttpOnly cookies
- Generating cryptographic random values
- A frontend page (the Completion Redirect URL) capable of:
- Reading URL fragments (
window.location.hash) - Making HTTP requests to your backend
- Reading URL fragments (
Step 1: Backend — Generate fe_code and Call /prepare
On your backend, when a user initiates phone verification:
// Node.js example
const crypto = require('crypto');
// Generate fe_code: 32 cryptographically random bytes, hex encoded
const feCode = crypto.randomBytes(32).toString('hex');
// Result: 64-character hex string, e.g., "a1b2c3d4..."
// Compute fe_hash: SHA256 of fe_code, hex encoded
const feHash = crypto.createHash('sha256').update(feCode).digest('hex');
// Result: 64-character hex string
// Call Glide /prepare endpoint
const response = await fetch('https://api.glideidentity.app/magic-auth/v2/auth/prepare', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Apigee adds Developer-Id, Developer-App-Id, Developer-Completion-Redirect-Url
// based on your API key
},
body: JSON.stringify({
nonce: crypto.randomUUID(),
use_case: 'VerifyPhoneNumber',
phone_number: '+15551234567',
plmn: { mcc: '310', mnc: '260' },
client_info: {
user_agent: userAgentFromRequest,
platform: platformFromRequest,
},
fe_hash: feHash, // REQUIRED for link protocol
}),
});
const prepareResult = await response.json();
// prepareResult.session.session_key → needed later
// prepareResult.data.data.url → the App Clip / OAuth URL
Critical: Use a cryptographically secure random number generator (
crypto.randomBytesin Node.js,os.urandomin Python,crypto/randin Go). Never useMath.random()or any non-cryptographic PRNG.
Step 2: Backend — Set the Cookie
In the HTTP response to your frontend, set the fe_code as an HttpOnly cookie:
// Node.js / Express example
// Use a session-scoped cookie name to prevent parallel session collisions
const sessionKey = prepareResult.session.session_key;
const cookieName = `_glide_bind_${sessionKey.substring(0, 16)}`;
res.cookie(cookieName, feCode, {
httpOnly: true, // REQUIRED: prevents XSS from reading the code
secure: true, // REQUIRED: only sent over HTTPS
sameSite: 'lax', // REQUIRED: 'lax' allows cookie on redirect navigations
path: '/', // REQUIRED: available for both /complete and /process endpoints
maxAge: 300000, // 5 minutes (auto-expires, no explicit clearing required)
});
// Return the prepare result to your frontend
res.json({
session_key: sessionKey,
app_clip_url: prepareResult.data.data.url,
});
Cookie requirements:
| Attribute | Value | Why |
|---|---|---|
HttpOnly | true | Prevents JavaScript from reading the cookie (XSS protection) |
Secure | true | Only sent over HTTPS connections |
SameSite | Lax | Sent on top-level navigations (required for the redirect flow) |
Path | / | Cookie must be available for both /complete and /process endpoints |
MaxAge | 300 seconds (5 min) | Auto-expires; no explicit clearing required |
Name | _glide_bind_{session_key_prefix} | Session-scoped to prevent parallel session collisions |
Step 3: Frontend — Open the App Clip URL
On your frontend, open the App Clip URL (or OAuth URL) so the user can authenticate:
// Open in a new tab or popup
const authWindow = window.open(appClipUrl, '_blank');
// Or use an iframe / App Clip Universal Link on iOS
Step 4: Frontend — Poll for Status
While the user authenticates, poll the public status endpoint:
const pollStatus = async (sessionKey) => {
const maxAttempts = 60; // 60 × 2s = 2 minutes
const interval = 2000; // 2 seconds
for (let i = 0; i < maxAttempts; i++) {
const response = await fetch(
`https://api.glideidentity.app/public/status/${sessionKey}`
);
const data = await response.json();
if (data.status === 'completed') {
return { success: true, status: 'completed' };
}
if (data.status === 'pending_completion') {
// Carrier auth succeeded, waiting for /complete
// The redirect page will handle this
return { success: true, status: 'pending_completion' };
}
if (data.status === 'failed') {
return { success: false, status: 'failed', reason: data.extra?.reason };
}
await new Promise(resolve => setTimeout(resolve, interval));
}
return { success: false, status: 'timeout' };
};
Note: For device-bound sessions, the status will transition from
pending→pending_completion(after carrier auth) →completed(after/complete). The frontend poller will seepending_completionfirst, thencompletedafter the redirect page calls your backend.
Step 5: Frontend — Handle the Redirect
After carrier authentication, Glide redirects to your Completion Redirect URL with the agg_code in the URL fragment:
https://your-app.com/auth/complete#agg_code=a1b2c3d4...&session_key=a1b2c3d4e5f6g7h8
If you are using the official BE SDKs, use getCompletionPageHtml('/api/auth/complete') to generate a production-ready completion page. It includes localStorage signaling, CSP headers, fragment cleanup, and polished error UI. Serve it at your registered completion redirect URL.
If self-implementing, your Completion Redirect Page must:
<!-- https://your-app.com/auth/complete -->
<html>
<head>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'">
</head>
<body>
<p>Completing verification...</p>
<script>
(async () => {
// Extract agg_code and session_key from URL fragment
const fragment = new URLSearchParams(window.location.hash.substring(1));
const aggCode = fragment.get('agg_code');
const sessionKey = fragment.get('session_key');
if (!aggCode || !sessionKey) {
document.body.innerHTML = '<p>Error: Missing authentication codes</p>';
return;
}
// Clear the fragment from the URL (defense-in-depth)
history.replaceState(null, '', window.location.pathname);
// Send to your backend
try {
const response = await fetch('/api/auth/complete', {
method: 'POST',
credentials: 'include', // IMPORTANT: sends the HttpOnly cookie
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agg_code: aggCode, session_key: sessionKey }),
});
if (response.ok) {
// Write localStorage signal for cross-tab notification
// The original tab's FE SDK listens for this StorageEvent
try {
localStorage.setItem('glide_signal_' + sessionKey, sessionKey);
setTimeout(function() {
try { localStorage.removeItem('glide_signal_' + sessionKey); } catch(e) {}
}, 5000);
} catch(e) {}
// Close tab immediately — no success text needed
window.close();
} else {
document.body.innerHTML = '<p>Verification failed. Please try again.</p>';
}
} catch (err) {
document.body.innerHTML = '<p>An error occurred. Please try again.</p>';
}
})();
</script>
</body>
</html>
Important: The
credentials: 'include'option is essential — it tells the browser to include cookies (including the HttpOnly_glide_bind_{prefix}cookie) in the request to your backend.
Important: The
localStorage.setItem('glide_signal_' + sessionKey, sessionKey)call is critical — this fires aStorageEventin other tabs on the same origin, which the FE SDK uses to detect that the flow completed (instead of relying solely on polling).
Step 6: Frontend — Send Codes to Your Backend
The redirect page (Step 5) sends agg_code and session_key to your backend. The fe_code is automatically included in the request via the HttpOnly cookie.
Step 7: Backend — Call /complete
On your backend, when you receive the complete request from your frontend:
// Node.js / Express example
app.post('/api/auth/complete', async (req, res) => {
const { agg_code, session_key } = req.body;
// Read fe_code from session-scoped cookie
const cookieName = `_glide_bind_${session_key.substring(0, 16)}`;
const feCode = req.cookies[cookieName];
if (!feCode || !agg_code || !session_key) {
return res.status(400).json({ error: 'Missing required parameters' });
}
// Call Glide /complete endpoint
const response = await fetch('https://api.glideidentity.app/magic-auth/v2/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_key: session_key,
fe_code: feCode,
agg_code: agg_code,
}),
});
if (!response.ok) {
const error = await response.json();
return res.status(response.status).json(error);
}
// Do NOT clear the cookie yet — it's needed for the /process step
return res.json({ status: 'completed' });
});
Step 8: Backend — Retrieve Verification Result
After /complete succeeds, the original tab's FE SDK detects completion (via StorageEvent or polling) and calls your /process endpoint. Your backend must include fe_code in the process request:
// Call /verify-phone-number with fe_code (validated again at process step)
app.post('/api/auth/process', async (req, res) => {
const { session_key, credential, use_case } = req.body;
// Read fe_code from session-scoped cookie
const cookieName = `_glide_bind_${session_key.substring(0, 16)}`;
const feCode = req.cookies[cookieName];
const result = await fetch('https://api.glideidentity.app/magic-auth/v2/auth/verify-phone-number', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session: {
session_key: session_key,
nonce: originalNonce,
protocol_type: 'link',
},
credential: credential,
fe_code: feCode, // Required for Link protocol — validated again
}),
});
const verifyResult = await result.json();
// { verified: true, phone_number: "+15551234567", sim_swap: {...}, device_swap: {...} }
});
Complete Self-Implementation Example (Node.js)
const express = require('express');
const crypto = require('crypto');
const cookieParser = require('cookie-parser');
const app = express();
app.use(express.json());
app.use(cookieParser());
const GLIDE_API_BASE = 'https://api.glideidentity.app';
// Store session data (use Redis/DB in production)
const sessions = new Map();
// Step 1-2: Initiate verification
app.post('/api/auth/start', async (req, res) => {
const { phoneNumber, mcc, mnc, userAgent, platform } = req.body;
// Generate fe_code
const feCode = crypto.randomBytes(32).toString('hex');
const feHash = crypto.createHash('sha256').update(feCode).digest('hex');
const nonce = crypto.randomUUID();
// Call Glide /prepare
const prepareRes = await fetch(`${GLIDE_API_BASE}/magic-auth/v2/auth/prepare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nonce,
use_case: 'VerifyPhoneNumber',
phone_number: phoneNumber,
plmn: { mcc, mnc },
client_info: { user_agent: userAgent, platform },
fe_hash: feHash,
}),
});
const prepareResult = await prepareRes.json();
if (!prepareRes.ok) {
return res.status(prepareRes.status).json(prepareResult);
}
// Store nonce for later use
sessions.set(prepareResult.session.session_key, { nonce });
// Set session-scoped HttpOnly cookie with fe_code
const cookieName = `_glide_bind_${prepareResult.session.session_key.substring(0, 16)}`;
res.cookie(cookieName, feCode, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 300000, // 5 minutes
});
res.json({
session_key: prepareResult.session.session_key,
auth_url: prepareResult.data?.data?.url,
});
});
// Step 7: Complete device binding
app.post('/api/auth/complete', async (req, res) => {
const { agg_code, session_key } = req.body;
const cookieName = `_glide_bind_${session_key.substring(0, 16)}`;
const feCode = req.cookies[cookieName];
if (!feCode || !agg_code || !session_key) {
return res.status(400).json({ error: 'Missing required parameters' });
}
const completeRes = await fetch(`${GLIDE_API_BASE}/magic-auth/v2/auth/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_key, fe_code: feCode, agg_code }),
});
if (!completeRes.ok) {
const error = await completeRes.json();
return res.status(completeRes.status).json(error);
}
// Do NOT clear cookie or retrieve result here.
// The original tab will call /process separately (fe_code validated again).
res.json({ status: 'completed' });
});
app.listen(3000);
Complete Self-Implementation Example (Python)
import hashlib
import os
import uuid
import requests
from flask import Flask, request, jsonify, make_response
app = Flask(__name__)
GLIDE_API_BASE = 'https://api.glideidentity.app'
# Store session data (use Redis/DB in production)
session_store = {}
@app.route('/api/auth/start', methods=['POST'])
def start_auth():
data = request.json
# Generate fe_code: 32 random bytes, hex encoded
fe_code = os.urandom(32).hex() # 64 hex chars
fe_hash = hashlib.sha256(fe_code.encode()).hexdigest() # 64 hex chars
nonce = str(uuid.uuid4())
# Call Glide /prepare
prepare_resp = requests.post(
f'{GLIDE_API_BASE}/magic-auth/v2/auth/prepare',
json={
'nonce': nonce,
'use_case': 'VerifyPhoneNumber',
'phone_number': data['phone_number'],
'plmn': {'mcc': data['mcc'], 'mnc': data['mnc']},
'client_info': {
'user_agent': data.get('user_agent', ''),
'platform': data.get('platform', ''),
},
'fe_hash': fe_hash,
},
)
if not prepare_resp.ok:
return jsonify(prepare_resp.json()), prepare_resp.status_code
result = prepare_resp.json()
session_key = result['session']['session_key']
# Store nonce
session_store[session_key] = {'nonce': nonce}
# Set HttpOnly cookie
resp = make_response(jsonify({
'session_key': session_key,
'auth_url': result.get('data', {}).get('data', {}).get('url'),
}))
cookie_name = f'_glide_bind_{session_key[:16]}'
resp.set_cookie(
cookie_name, fe_code,
httponly=True, secure=True, samesite='Lax',
path='/', max_age=300,
)
return resp
@app.route('/api/auth/complete', methods=['POST'])
def complete_auth():
data = request.json
session_key = data.get('session_key', '')
cookie_name = f'_glide_bind_{session_key[:16]}'
fe_code = request.cookies.get(cookie_name)
if not fe_code or not data.get('agg_code') or not session_key:
return jsonify({'error': 'Missing required parameters'}), 400
# Call Glide /complete
complete_resp = requests.post(
f'{GLIDE_API_BASE}/magic-auth/v2/auth/complete',
json={
'session_key': data['session_key'],
'fe_code': fe_code,
'agg_code': data['agg_code'],
},
)
if not complete_resp.ok:
return jsonify(complete_resp.json()), complete_resp.status_code
# Retrieve verification result
session_data = session_store.get(data['session_key'], {})
verify_resp = requests.post(
f'{GLIDE_API_BASE}/magic-auth/v2/auth/verify-phone-number',
json={
'session': {
'session_key': data['session_key'],
'nonce': session_data.get('nonce'),
'protocol_type': 'link',
},
},
)
# Do NOT clear cookie — needed for the /process step
return jsonify({'status': 'completed'})
if __name__ == '__main__':
app.run(port=3000, ssl_context='adhoc') # HTTPS required
Complete Self-Implementation Example (Go)
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
)
const glideAPIBase = "https://api.glideidentity.app"
var (
sessionStore = map[string]string{} // session_key -> nonce
storeMu sync.Mutex
)
func generateFeCode() (feCode string, feHash string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
feCode = hex.EncodeToString(b)
hash := sha256.Sum256([]byte(feCode))
feHash = hex.EncodeToString(hash[:])
return feCode, feHash, nil
}
func startAuthHandler(w http.ResponseWriter, r *http.Request) {
var reqBody struct {
PhoneNumber string `json:"phone_number"`
MCC string `json:"mcc"`
MNC string `json:"mnc"`
}
json.NewDecoder(r.Body).Decode(&reqBody)
feCode, feHash, err := generateFeCode()
if err != nil {
http.Error(w, `{"error":"failed to generate fe_code"}`, 500)
return
}
nonce := generateNonce()
prepareBody, _ := json.Marshal(map[string]interface{}{
"nonce": nonce,
"use_case": "VerifyPhoneNumber",
"phone_number": reqBody.PhoneNumber,
"plmn": map[string]string{"mcc": reqBody.MCC, "mnc": reqBody.MNC},
"fe_hash": feHash,
})
resp, err := http.Post(
glideAPIBase+"/magic-auth/v2/auth/prepare",
"application/json",
strings.NewReader(string(prepareBody)),
)
if err != nil || resp.StatusCode != 200 {
http.Error(w, `{"error":"prepare failed"}`, 502)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var prepareResult map[string]interface{}
json.Unmarshal(body, &prepareResult)
sessionMap := prepareResult["session"].(map[string]interface{})
sessionKey := sessionMap["session_key"].(string)
storeMu.Lock()
sessionStore[sessionKey] = nonce
storeMu.Unlock()
cookieName := "_glide_bind_" + sessionKey[:16]
http.SetCookie(w, &http.Cookie{
Name: cookieName, Value: feCode,
Path: "/", MaxAge: 300,
HttpOnly: true, Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"session_key": sessionKey,
"auth_url": prepareResult["data"],
})
}
func completeAuthHandler(w http.ResponseWriter, r *http.Request) {
var reqBody struct {
AggCode string `json:"agg_code"`
SessionKey string `json:"session_key"`
}
json.NewDecoder(r.Body).Decode(&reqBody)
cookieName := "_glide_bind_" + reqBody.SessionKey[:16]
cookie, err := r.Cookie(cookieName)
if err != nil || cookie.Value == "" || reqBody.AggCode == "" {
http.Error(w, `{"error":"missing required parameters"}`, 400)
return
}
completeBody, _ := json.Marshal(map[string]string{
"session_key": reqBody.SessionKey,
"fe_code": cookie.Value,
"agg_code": reqBody.AggCode,
})
resp, _ := http.Post(
glideAPIBase+"/magic-auth/v2/auth/complete",
"application/json",
strings.NewReader(string(completeBody)),
)
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
w.WriteHeader(resp.StatusCode)
w.Write(respBody)
return
}
storeMu.Lock()
nonce := sessionStore[reqBody.SessionKey]
delete(sessionStore, reqBody.SessionKey)
storeMu.Unlock()
verifyBody, _ := json.Marshal(map[string]interface{}{
"session": map[string]string{
"session_key": reqBody.SessionKey, "nonce": nonce,
"protocol_type": "link",
},
})
verifyResp, _ := http.Post(
glideAPIBase+"/magic-auth/v2/auth/verify-phone-number",
"application/json",
strings.NewReader(string(verifyBody)),
)
defer verifyResp.Body.Close()
result, _ := io.ReadAll(verifyResp.Body)
// Do NOT clear cookie — needed for the /process step
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"completed"}`))
}
func generateNonce() string {
b := make([]byte, 16)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
func main() {
http.HandleFunc("/api/auth/start", startAuthHandler)
http.HandleFunc("/api/auth/complete", completeAuthHandler)
fmt.Println("Server listening on :3000")
http.ListenAndServeTLS(":3000", "cert.pem", "key.pem", nil)
}
Error Handling Reference
| HTTP Status | Error Code | Meaning | Action |
|---|---|---|---|
| 400 | BAD_REQUEST | Missing fe_hash, invalid format, non-link protocol | Fix request parameters |
| 400 | VALIDATION_ERROR | Missing required fields or Developer-Completion-Redirect-Url | Fix request or register redirect URL in Apigee |
| 403 | FORBIDDEN | Device binding validation failed (code mismatch) | Restart the entire flow from /prepare |
| 404 | SESSION_NOT_FOUND | Session expired, not found, or wrong developer | Create a new session |
| 409 | SESSION_NOT_ELIGIBLE | Session not in pending_completion (e.g., already completed, or still pending) | Check session status; wait for carrier auth or restart |
| 410 | SESSION_EXPIRED | Session TTL exceeded | Create a new session |
| 500 | INTERNAL_SERVER_ERROR | Infrastructure error (Redis, etc.) | Safe to retry the same /complete request |
Key error handling rules:
- 403 on
/complete→ restart the entire flow. Do not try to guess which code was wrong. The session's device binding is compromised. - 500 on
/complete→ retry. Infrastructure errors are transient. The session is still inpending_completionand the codes have not been invalidated. - 409 on
/complete→ check the session status. Ifcompleted, the prior/completesucceeded (race condition). Iffailed, the session was cleaned up.
Security Design Decisions
Why Two Codes (Not One, Not Three)
Why not one code? A single code proves only one thing: either "same browser" (cookie) or "same device received redirect" (fragment). An attacker who controls one channel could still complete the flow.
Why not three codes? The original design had three codes (fe_code, be_code, agg_code). After security review, be_code was dropped because:
- Apigee already authenticates the developer's backend on every API call
- Developer ID validation at the session level (
GetSessionForDeveloper) provides the same cross-developer protection - It simplifies SDK integration (developers don't need to generate/store/retrieve
be_code)
Two codes is the sweet spot: fe_code proves browser continuity, agg_code proves device received the redirect. Together they bind the authentication to both the initiating browser and the completing device.
Why fe_code Is in an HttpOnly Cookie
- HttpOnly prevents XSS attacks from reading the code
- Secure ensures the code only travels over HTTPS
- SameSite=Lax allows the cookie to be sent on top-level navigations (the redirect from Glide to the completion page) while preventing cross-site subrequests
- Cookies are automatically attached by the browser — no client-side code needed to manage them
- The developer only needs to
Set-Cookieonce; the browser handles the rest - Session-scoped name (
_glide_bind_{session_key_prefix}) prevents parallel session collisions
Why agg_code Is in a URL Fragment
- URL fragments (after
#) are never sent to any server in HTTP requests - They don't appear in server access logs
- They don't appear in
Refererheaders - They are only accessible by JavaScript running on the page
- This means
agg_codeis only visible to client-side code on the developer's registered redirect page - Even if an attacker intercepts the HTTP request, they cannot see the fragment
Why CarrierVerified Is Separate from Verified
The session has two boolean fields:
CarrierVerified: Set by the OAuth callback to the carrier's actual verification resultVerified: Only set totrueby/completeafter both codes are validated
This separation prevents a bypass where existing code paths check session.Verified == true to short-circuit the flow. Since Verified is false until /complete succeeds, all existing endpoints correctly reject the session.
Why pending_completion Is a Distinct Status
Without this status, the session would transition from pending directly to completed in the OAuth callback. This creates a race condition:
- The callback sets
completed+Verified=true - The frontend poller sees
completedand calls/verify-phone-number - The attacker skips the device binding entirely
With pending_completion:
- The callback sets
pending_completion+CarrierVerified=true+Verified=false - The poller sees
pending_completionbut cannot retrieve credentials - Only
/completecan transition tocompleted
Why No Backward Compatibility / Opt-Out
Device binding is mandatory for all link protocol sessions. This is a deliberate design decision:
- Optional security features get skipped (by developers and attackers)
- "Backward compatible" would mean maintaining the vulnerable code path
- All link protocol sessions are equally protected
Rate Limiting
Rate limiting for /complete is handled by Apigee at the network layer. The original design included in-app rate limiting (max 5 attempts → kill session), but this was removed because:
- It was a DoS vector: attackers who knew the
session_keycould send garbage requests to kill legitimate sessions - Apigee already enforces per-developer, per-IP rate limits
Timing Attack Prevention
The /complete endpoint uses constant-time comparison (subtle.ConstantTimeCompare) for both hash validations:
feResult := subtle.ConstantTimeCompare([]byte(feHash), []byte(storedFe))
aggResult := subtle.ConstantTimeCompare([]byte(aggHash), []byte(storedAgg))
return feResult & aggResult == 1
Both codes are always validated even if one fails, preventing timing side-channels that could reveal which code is correct. The stored hashes are also normalized to a fixed length to prevent short-circuiting on length mismatch.
Apigee Configuration Requirements
For device binding to work, the following must be configured in Apigee for each developer:
-
Developer-Completion-Redirect-Urlheader — The developer's registered HTTPS redirect URL must be configured as a custom attribute in Apigee and injected as a header on every request to Glide. -
Developer-Idheader — The developer's unique identifier (already standard). -
Rate limiting — Per-developer, per-IP rate limits on
/completeshould be configured to prevent brute-force attacks on the codes (even though each code is 256 bits of entropy, defense-in-depth is appropriate). -
Route registration — The
/magic-auth/v2/auth/completeendpoint must be registered in the Apigee proxy and require authentication.
Glossary
| Term | Definition |
|---|---|
| fe_code | Frontend code. A 32-byte random value (64 hex chars) generated by the developer's backend, delivered to the browser as an HttpOnly cookie. Proves same-browser continuity. |
| fe_hash | SHA256 hash of fe_code. Stored on the session during /prepare. The preimage (fe_code) is never stored server-side. |
| agg_code | Aggregator code. A 32-byte random value (64 hex chars) generated by Glide's aggregator during the OAuth callback. Delivered via URL fragment. Proves the device received the carrier redirect. |
| agg_hash | SHA256 hash of agg_code. Stored on the session during the OAuth callback. |
| CarrierVerified | Internal session field set by the OAuth callback to the carrier's actual verification result (true/false). Not visible to API consumers. |
| Verified | Session field that is only set to true after /complete validates both codes. This is the "real" verification flag used by /verify-phone-number. |
| pending_completion | Session status indicating carrier auth succeeded but device binding has not yet been validated. Only /complete can transition from this status to completed. |
| CompletionRedirectURL | The developer's registered HTTPS URL where Glide redirects after carrier authentication. The agg_code is delivered in the URL fragment of this redirect. |
| Device binding | The cryptographic mechanism that ties an authentication session to the specific device/browser that initiated it, preventing session fixation attacks. |
| Link Protocol | The OAuth-based carrier authentication protocol used for carriers like Verizon. Device binding is mandatory for all link protocol sessions. |
| App Clip | An iOS feature that allows users to use a small part of an app without downloading the full app. Used by some carriers for on-device authentication. |
Last Updated: February 2026