GlassKit

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.

packages/backend/convex/pairing.ts
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:

packages/backend/convex/pairing.ts
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: /pairpendingSessionsForIp + 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:

packages/backend/convex/pairing.ts
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:

packages/backend/convex/pairing.ts
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: confirmPairingSessionmintAndAttach

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."

packages/backend/convex/pairing.ts
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: confirmByCodemintAndAttach

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.

packages/backend/convex/pairing.ts
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, never claimed. 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:

packages/backend/convex/pairing.ts
const SESSION_EXPIRES_SECONDS = 120; // 2 min to claim + confirm

Shorten 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>", …):

packages/backend/convex/rateLimiter.ts
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) caps previewByCode / confirmByCode attempts 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:

app/src/main.tsx
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:5173 shares the Clerk session with localhost:3000 because 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 /pair on the same machine. Both surfaces share the same egress IP (localhost), so the Display shows up on /pair immediately; 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

  • 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.

On this page