Cross-device pairing
How the glasses inherit the Clerk session from the companion: same-network device pairing with on-glasses approval, end to end.
Your user pays on the companion site. Minutes later they put on
the glasses, tap Start Pairing, and link the two devices: on
the same network, they tap their Display once on the companion's
/pair page and approve on the glasses; on a different network,
they type the short code the glasses show and confirm a 2-digit
number on the phone. Either way they're signed in. No password on
a D-pad. No QR to scan. This is the bit of GlassKit that doesn't
exist in Meta's free starter and is hardest to wire correctly.
The primary mechanism is same-network device pairing with an
on-glasses approval step. The glasses register an anonymous
pairing session tagged with their network's egress IP and wait;
the user's phone, on the same Wi-Fi and already signed into the
companion, sees that Display listed on /pair and taps it; the
glasses prompt "Pair with <your account>? Yes / No" and
the wearer taps Yes. Only then is a Clerk Sign-In Ticket
minted server-side and exchanged via signIn.create. The ticket
never crosses a URL boundary, and the consent decision lives on
the glasses, where only the wearer can see and make it.
When the phone and glasses aren't on the same network (phone
on cellular, guest Wi-Fi), same-network discovery can't link them.
For that there's a code fallback: the glasses always show a
short code and a verify number on the waiting screen, the user
types the code into /pair and confirms that the verify number
matches, and the same Sign-In Ticket is minted. The verify-match
moves the consent onto the phone (see
The code fallback below).
The flow at a glance
Why on-glasses approval on the same-network path
On the same-network path the final "Pair with <account>?"
approval happens on the glasses, so the wearer is the
gatekeeper. A stranger on the same network who taps your Display
on their /pair page just makes your glasses show a prompt you
decline; they can't complete the pairing without the Yes that
only you can tap. The consent boundary is the on-glasses confirm,
not a secret code, so there's nothing to type on a D-pad and
nothing to scan. "Same network" means same public egress IP,
detected by the companion when the glasses register. (The
code fallback moves this
consent onto the phone instead, via a verify-number match.)
Why this shape fits the Display
The Display is a near-eye HUD (only the wearer sees it) with no camera and arrow-keys-only input. It can't show a scannable QR, can't scan one, and typing a code on it is miserable. Same-network discovery plus on-glasses approval is the seamless fit: the wearer's two devices find each other over the network, and the wearer confirms with a single tap.
The pieces
Register: createPairingSession (via POST /api/pair/register)
When the wearer taps Start Pairing, the glasses app calls the
companion route POST /api/pair/register. That route captures the
request's egress IP and calls the public Convex mutation
createPairingSession, which inserts a row into the
pairing_sessions table tagged with that IP and a freshly
generated 120-bit secret sessionId.
export const createPairingSession = mutation({
args: { glassesIp: v.string() },
handler: async (ctx, { glassesIp }) => {
await rateLimiter.limit(ctx, "pairingRegister", {
key: glassesIp,
throws: true,
});
const sessionId = generateSessionId(); // 120-bit, URL-safe
const code = randomCode(); // unique 6-char code
const verifyCode = generateVerifyCode(); // 2-digit verify number
const expiresAt = Date.now() + SESSION_EXPIRES_SECONDS * 1_000;
await ctx.db.insert("pairing_sessions", {
sessionId,
code,
verifyCode,
glassesIp,
status: "pending",
createdAt: Date.now(),
expiresAt,
});
return { sessionId, code, verifyCode, expiresAt };
},
});The route + mutation:
- Captures the egress IP on the server. The companion route reads it from the request (not from a client-supplied value), so the glasses can't spoof which network they claim to be on.
- Rate-limits the register:
pairingRegister, keyed per IP. Defeats session-flooding from one network. - Generates a 120-bit
sessionId. The glasses hold this secret and use it to subscribe to their own session's state. It never appears in any UI. - Is anonymous. No Clerk identity is attached yet. The session is just a pending row waiting for a phone to claim it and the wearer to confirm.
Watch: watchPairingSession
The glasses subscribe to a reactive Convex query keyed by their
secret sessionId:
export const watchPairingSession = query({
args: { sessionId: v.string() },
handler: async (ctx, { sessionId }) => {
const session = await ctx.db
.query("pairing_sessions")
.withIndex("by_session", (q) => q.eq("sessionId", sessionId))
.unique();
if (!session) return null;
// status: "pending" → "claimed" → "confirmed" → "ready"
return {
status: session.status,
claimingLabel: session.claimingLabel ?? null,
};
},
});Because it's a Convex reactive query, the glasses UI re-renders the moment a phone claims the session; that's what flips the screen from "waiting" to the "Pair with <account>?" prompt with no polling.
Discover + claim: /pair → pendingSessionsForIp + claimPairingSession
On the phone, the user opens the companion's /pair page (same
Wi-Fi, already signed into the companion). The page calls
pendingSessionsForIp, which lists the Displays currently pairing
on that phone's egress IP:
export const pendingSessionsForIp = query({
args: { glassesIp: v.string() },
handler: async (ctx, { glassesIp }) => {
return await ctx.db
.query("pairing_sessions")
.withIndex("by_ip_status", (q) =>
q.eq("glassesIp", glassesIp).eq("status", "pending"),
)
.collect();
},
});The user taps their Display. That fires the authenticated
claimPairingSession authMutation: it runs with the phone
user's Clerk identity, validates the session, stamps it with that
account, and flips its status to claimed, all in one mutation:
export const claimPairingSession = authMutation({
args: { sessionId: v.string() },
handler: async (ctx, { sessionId }) => {
// The wrapper already threw UNAUTHENTICATED if no Clerk identity;
// ctx.identity is the validated identity for the signed-in caller.
await rateLimiter.limit(ctx, "pairingClaim", {
key: ctx.identity.subject,
throws: true,
});
const session = await ctx.db
.query("pairing_sessions")
.withIndex("by_session", (q) => q.eq("sessionId", sessionId))
.unique();
if (!session) throw new ConvexError({ code: "PAIRING_SESSION_NOT_FOUND" });
await ctx.db.patch(session._id, {
status: "claimed",
clerkUserId: ctx.identity.subject,
claimingLabel:
ctx.identity.name ?? ctx.identity.email ?? "your account",
});
},
});claimPairingSession only proposes an account. Nothing is signed
in yet; the claim just populates the label the glasses will show
in the approval prompt.
More than one Display on the network?
When several Displays are pairing on the same egress IP at once,
/pair lists each one with its 2-digit verify number (e.g.
"Ray-Ban Display #44"), and the glasses show that same number
on their waiting screen. Match the number on your glasses to the
row in the list, and tap that one. (It's the same verify number
the code fallback uses for
its verify-match.) The on-glasses "Pair with <account>?"
confirm is still the final gate, so tapping the wrong row just makes
that Display prompt, which its wearer declines.
Approve + sign in: confirmPairingSession → mintAndAttach
The glasses, watching the session, now render
"Pair with <account>? Yes / No". The wearer taps
Yes, which calls confirmPairingSession. That confirm is the
consent boundary; it kicks off internal mintAndAttach, which
mints the Clerk Sign-In Ticket for the claimed account and attaches
it to the session:
// glasses side, after the wearer taps Yes
await confirmPairingSession({ sessionId });
// status flips to "ready"; watchPairingSession now returns the ticket
const ticket = /* read from the confirmed session */;
if (isSignedIn) await signOut(); // stale-session: new ticket wins
await signIn.create({ strategy: "ticket", ticket });
await setActive({ session: signIn.createdSessionId });mintAndAttach (internal) wraps createClerkClient +
signInTokens.createSignInToken. The ticket is minted only after
the on-glasses Yes, so a claim alone never produces a credential.
Once signIn.create succeeds, Clerk persists the session on the
glasses, so later launches skip pairing entirely.
Stale-session sign-out
If the glasses already hold an old Clerk session (shared/loaner glasses, or a developer testing two accounts on the same hardware), the new ticket should win. Sign out first, then exchange the ticket.
MFA / step-up branches
A D-pad surface can't complete MFA. If the claimed Clerk account
has MFA enabled and the ticket flow returns needs_first_factor,
needs_second_factor, or needs_identifier, the glasses tell the
wearer to clear MFA on the companion, then start pairing again.
The code fallback (different networks)
The same-network path can't link a phone on cellular to glasses on
Wi-Fi; there's no shared egress IP for pendingSessionsForIp to
match on. The code fallback covers that case. Every pairing
session already carries the data it needs: alongside the secret
sessionId, createPairingSession now also generates a short
code (6 chars, e.g. DQFWT1) and a 2-digit verify number
(e.g. 44), both stored on the pairing_sessions row.
The glasses always show the code + verify number
Beneath the same-network instructions on the waiting screen, the
glasses display the code and the verify number: DQFWT1 / 44.
They're shown on every session, so the fallback is always available
without the wearer doing anything different.
The user types the code on /pair
Below the discovered-Displays list, /pair (signed in, on any
network) has a "Not on the same network?" field. The user types
the code from the glasses and taps Continue, which fires the
authenticated previewByCode.
Confirm the verify number matches: previewByCode
The companion then shows that session's verify number, big ("Does your Display show 44?") and names the account it would sign in: "This signs it into <your account>." The user glances at the glasses they're wearing, checks the number matches, and taps "Yes, that's mine."
export const previewByCode = authMutation({
args: { code: v.string() },
handler: async (ctx, { code }): Promise<{ verifyCode: string }> => {
// ctx.identity is the validated signed-in caller.
await rateLimiter.limit(ctx, "pairingCodeAttempt", {
key: ctx.identity.subject,
throws: true,
});
const session = await ctx.db
.query("pairing_sessions")
.withIndex("by_code", (q) => q.eq("code", normalizePairingCode(code)))
.unique();
if (!session) throw new ConvexError({ code: "NOT_FOUND" });
return { verifyCode: session.verifyCode };
},
});previewByCode is authenticated (it runs as the signed-in phone
user) and rate-limited via the pairingCodeAttempt bucket. It only
reads the verify number bound to the entered code; nothing is
signed in at this step.
Sign in: confirmByCode → mintAndAttach
When the user taps "Yes, that's mine," the companion fires
confirmByCode. It claims and confirms in one step (the
status goes straight to confirmed, skipping claimed) and
schedules the same internal mintAndAttach the network path uses.
The glasses, watching the session, pick up the minted ticket and
run the identical signIn.create({ strategy: "ticket" }) exchange.
export const confirmByCode = authMutation({
args: { code: v.string() },
handler: async (ctx, { code }) => {
// ctx.identity is the validated signed-in caller.
await rateLimiter.limit(ctx, "pairingCodeAttempt", {
key: ctx.identity.subject,
throws: true,
});
const session = await ctx.db
.query("pairing_sessions")
.withIndex("by_code", (q) => q.eq("code", normalizePairingCode(code)))
.unique();
if (!session) throw new ConvexError({ code: "NOT_FOUND" });
// claim + confirm in one step: status → "confirmed", skipping "claimed"
await ctx.db.patch(session._id, {
status: "confirmed",
clerkUserId: ctx.identity.subject,
claimingLabel:
ctx.identity.name ?? ctx.identity.email ?? "your account",
});
// schedule the same mintAndAttach the same-network path uses
await ctx.scheduler.runAfter(0, internal.pairing.mintAndAttach, {
sessionId: session.sessionId,
});
},
});Why the verify-match (security)
The code path is the classic device-flow phishing surface. Because the user types a code that they obtained, an attacker could try to trick a signed-in victim into typing the attacker's code, which would sign the attacker's glasses into the victim's account. The 2-digit verify-match defeats this: the number the companion shows is bound to the entered code, so it only matches the glasses the user is actually wearing. An attacker's code shows a different number, and a careful user declines. This is the Bluetooth / WhatsApp-style "confirm the numbers match" pattern.
A few properties make this safe:
- The mint happens only after "Yes, that's mine." That tap is
authenticated as the phone user and rate-limited
(
pairingCodeAttempt), so a guessed or harvested code never produces a credential on its own. - Consent lives on the phone, by the verify-match. Unlike the same-network path, where consent is the on-glasses "Pair with X?", the code path's consent is the user confirming the number matches the Display they're wearing.
- The session goes straight to
confirmed, neverclaimed. So the glasses never render a Yes/No that someone holding them could press; the only decision is the verify-match on the phone.
Same-network vs. code path consent
Both paths mint the same Clerk Sign-In Ticket via
mintAndAttach; only the consent step differs. Same network:
the wearer taps Yes on the glasses. Different network: the
signed-in user confirms the verify number matches on the
phone. Pick whichever your user's network situation allows; the
result is identical.
Customizing
Change the session expiry
In packages/backend/convex/pairing.ts:
const SESSION_EXPIRES_SECONDS = 120; // 2 min to claim + confirmShorten for paranoid scenarios (loaner devices in a public space, in-person handoff at events). Lengthen if your users are slow to reach for their phone after tapping Start Pairing. The default 2-minute window covers the "tap Start, grab your phone, tap your Display, tap Yes" flow with comfortable headroom.
Different rate limits
Three buckets in packages/backend/convex/rateLimiter.ts, all
consumed inline via rateLimiter.limit(ctx, "<bucket>", …):
pairingRegister: { kind: "token bucket", rate: 30, period: HOUR, capacity: 10 },
pairingClaim: { kind: "token bucket", rate: 30, period: HOUR, capacity: 10 },
pairingCodeAttempt: { kind: "token bucket", rate: 30, period: HOUR, capacity: 10 },pairingRegister(keyed per egress IP) caps how many pending sessions a single network can open. Increase only for genuinely busy shared networks (a co-working space full of testers).pairingClaim(keyed per user) caps how often one signed-in account can claim Displays. The default leaves plenty of headroom for retries while bounding abuse from a compromised companion session.pairingCodeAttempt(keyed per user) capspreviewByCode/confirmByCodeattempts on the code fallback, so a signed-in account can't grind through codes. Keep this tight; the code space is small enough that the rate limit is part of what makes guessing impractical.
Skip pairing for a specific route
<PairingGate> wraps <App> in app/src/main.tsx. If you have
public-facing glasses screens that should be reachable without
auth (a kiosk demo, a public weather view), branch above the gate:
const isPublicRoute = window.location.pathname.startsWith("/public");
return (
<ClerkProvider publishableKey={env.VITE_CLERK_PUBLISHABLE_KEY}>
<GlassViewport>
{isPublicRoute ? <PublicApp /> : <PairingGate><App /></PairingGate>}
</GlassViewport>
</ClerkProvider>
);<GlassViewport> stays as the outermost UI wrapper so both branches
are bounded inside the 600×600 surface.
The companion's CORS allowlist
POST /api/pair/register only accepts cross-origin calls from your
deployed glasses app. The allowed origin is GLASSES_APP_URL (the
same env var the deploy guide covers). If you serve the glasses app
from a new domain, update GLASSES_APP_URL so the register route
keeps accepting its requests. See Deploy.
Local dev
The local-pairing options in Quickstart §5 all preserve the same flow:
- Browser preview:
localhost:5173shares the Clerk session withlocalhost:3000because both apps point at the same Convex URL + Clerk publishable key. The cookie is already there, so you rarely need to pair at all. - Exercising the real pairing flow locally: tap Start
Pairing in the glasses app, then open
/pairon the same machine. Both surfaces share the same egress IP (localhost), so the Display shows up on/pairimmediately; tap it, then approve on the glasses.
The full same-network flow only matters in production, where your user's phone is a different device from the glasses but sits on the same Wi-Fi. Locally, both surfaces share one egress IP, so discovery just works.
Debugging
Why not magic-link, OAuth, password, or a QR?
- Magic-link: requires the user to type an email/code on a D-pad. Same UX problem we're solving.
- OAuth redirect: would work, but requires the glasses app to open a system browser (Meta AI app doesn't), then redirect back. Sign-In Tokens skip the browser hop entirely.
- Password: D-pad. No.
- A scannable QR on the dashboard: the Display has no camera, so it can't scan one; and rendering a QR on the Display for the phone to scan fights the near-eye optics. Same-network discovery needs no optics on either side.
- A typed short code: miserable on arrow-keys-only input, and unnecessary: the on-glasses Yes already gives the wearer the final say, so there's no secret to type.
Same-network discovery + on-glasses approval is the shape that fits a cameraless, keyboardless, single-wearer HUD: the wearer's two devices find each other over the network they already share, and the wearer confirms with one tap. The Clerk Sign-In Token stays the underlying primitive, minted only after that tap, never on a URL.