Latchkey
Docs navigation

Docs

Quickstarts

Sign users in from a single-page app, a React Native app, or verify tokens in a Go backend — the three Latchkey SDKs in a few minutes each.

Three thin SDKs, one per surface: a browser SPA, a React Native app, and the backend that verifies what they send. Each wraps the part that’s easy to get subtly wrong — PKCE, rotating refresh tokens, attestation — and nothing else.

Packages: the JavaScript SDKs are landing on npm; until then they ship from the latchkeyid/ui repo (spa/ and native/, zero-dependency, MIT). The Go module is github.com/latchkeyid/latchkey/auth.

Single-page app — @latchkey/spa

PKCE authorize, the callback exchange, claims, and an authFetch that refreshes and retries once on a 401. Refresh tokens rotate — spending one twice signs the user out everywhere — so refresh is single-flight per page and serialized across tabs with Web Locks. That detail is most of why this SDK exists.

import { createLatchkey } from "@latchkey/spa";

const lk = createLatchkey({
  issuer: "https://auth.latchkey.id", // or your org's auth domain
  clientId: "",
});

// on your /callback route
const { returnTo } = await lk.handleCallback();
router.navigate(returnTo);

// anywhere
if (!lk.signedIn()) lk.signIn({ returnTo: location.pathname });
const res = await lk.authFetch("/api/things");
const who = lk.claims()?.email;
lk.signOut();
  • redirectUri defaults to {origin}/callback — register it on your client.

  • Call lk.authFetch(...) on the object — it binds this; destructuring breaks it.

  • claims() decodes locally for display. Authorization belongs to your backend, which verifies.

React Native / Expo — @latchkey/native

Phone and email-code sign-in in the two-call shape you know from Supabase — no browser round trip — plus the device-attestation handshake and secure token storage. The SDK carries no native code: you inject storage and (optionally) the attestation SDK call.

import * as SecureStore from "expo-secure-store";
import { createLatchkeyNative, MfaRequiredError } from "@latchkey/native";

const lk = createLatchkeyNative({
  issuer: "https://auth.latchkey.id",
  clientId: "",
  storage: {
    getItem: SecureStore.getItemAsync,
    setItem: SecureStore.setItemAsync,
    removeItem: SecureStore.deleteItemAsync,
  },
});

// sign in by SMS code — two calls, like Supabase's signInWithOtp/verifyOtp
const { ticket } = await lk.startPhone("+61400000123");
const claims = await lk.verifyPhone(ticket, codeFromTheTextMessage);

// then, anywhere
const res = await lk.authFetch("https://api.yourproduct.com/things");

startEmailCode/verifyEmailCode are the same pair over email. Refresh is single-flight and rotation-safe, same as the SPA.

Device attestation

If your client requires attestation (Play Integrity / App Attest), inject the platform SDK call — Latchkey handles the challenge round trip:

attest: async challenge => ({
  platform: "android",
  token: await requestIntegrityToken(challenge), // Play Integrity SDK
}),

MFA: an account armed with two-factor can’t finish a browserless grant. Catch MfaRequiredError and bounce that user to the browser flow. Using Supabase for data? Keep supabase-js in accessToken mode — accessToken: () => lk.getAccessToken() — and your migration is auth-only.

Backend — github.com/latchkeyid/latchkey/auth

A zero-dependency Go verifier for your product’s gateway: discovery + JWKS (cached, key rotation handled), RS256 verification, and helpers over the token’s ns claims.

import "github.com/latchkeyid/latchkey/auth"

verifier := auth.New("https://auth.latchkey.id")
mux.Handle("/api/", verifier.Middleware(apiHandler))

// in a handler
claims := auth.FromContext(r.Context())
if claims == nil || !claims.Has("grapevine/home-123", "member") {
    http.Error(w, "forbidden", http.StatusForbidden)
    return
}
if claims.HasRole("grapevine/home-123", "committee") { /* … */ }
  • Has(ns, min) checks the exact namespace (plus the staff wildcard). Levels: viewer < member < owner/admin < staff.

  • FromContext is nil outside the middleware — check it.

  • Set auth.WithAudience(...) if you mint audience-scoped tokens.

The line that keeps this fast: authorize from the token’s claims plus your own synced data — never by calling Latchkey per request. Role changes land at the next token refresh; short access-token TTLs (per-client, configurable) are the staleness knob.

Where the pieces live

  • Clients, tenants, roles, teams, grants and API keys as code: the Terraform provider.

  • Custom claims from your own backend at sign-in: auth hooks.

  • Discovery: https://auth.latchkey.id/.well-known/openid-configuration.