npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@deepkey/sdk

v2.0.0

Published

DeepKey authentication SDK for the browser and server — a supabase-js compatible auth surface

Readme

@deepkey/sdk

Authentication SDK for DeepKey — passwordless and social sign-in with a supabase-js compatible surface. If you have code written against supabase.auth.*, the call sites, response envelopes, and auth events port 1:1.

The full reference lives at deepkey.dev/sdk.

Two entry points:

| Import | Runs in | Key used | Purpose | |---|---|---|---| | @deepkey/sdk | Browser (SPA) | Publishable key (bt_pub_…) | Sign-in flows, session management, auto-refresh | | @deepkey/sdk/server | Server (Node ≥ 18, Bun, edge) | Secret key (bt_secret_…) | Validate access tokens, fetch the user |

npm install @deepkey/sdk

Core concepts

  • Hosted API — every request goes to https://deepkey.dev/api/auth/v1/*. The URL is built in; you only pass a key.
  • Project — each consuming app is a project on DeepKey. The project is resolved from the API key sent with every request (apikey header).
  • Publishable key (bt_pub_<project-slug>_<random>) — safe to ship in browser bundles. Identifies the project; grants no privileged access.
  • Secret key (bt_secret_…) — server-side only. Never expose it in client code.
  • Session — { access_token, refresh_token, token_type: 'bearer', expires_in, expires_at, user }. The access token is an ES256 JWT (short-lived); the refresh token is opaque and rotates on every refresh.
  • Sign-in flows — passwordless only: email OTP (6-digit code), magic link, and OAuth (Google, X). There is no password sign-in. Magic link and OAuth complete via the PKCE flow by default: DeepKey redirects back to your app with a one-time ?code=… that only the browser which started the sign-in can exchange, and the client does that automatically. The older implicit flow (tokens in the URL fragment) is opt-in with flowType: 'implicit'.
  • Redirect allow-list — every redirectTo / emailRedirectTo URL must be allow-listed in the project's settings on the DeepKey console (wildcards supported: in the host * matches within one label — partial labels like *-team.example.com included — and ** several labels, in the port * matches any port, in the path * doesn't cross / or . and ** crosses everything; the host must be plain ASCII, so write IDN hosts in punycode, and a wildcard may not cover the registrable domain such as *.com — nor, under a shared hosting suffix, the name directly below it, so *-team.vercel.app and *.github.io are rejected while app.vercel.app is fine). An accepted redirect is used in its canonical form (lower-case host, no default port, normalized path); one with credentials, percent-encoding or \ in the host is treated as not listed. ⚠️ A redirect that is not on the list is not an error — the service silently falls back to the project's Site URL (same semantics as Supabase). If your login "works but lands on the wrong page", this is why.

Response envelope (never throws)

Every method resolves to { data, error } — network and API failures are returned, not thrown:

const { data, error } = await deepkey.auth.getUser()
if (error) {
  // error is an AuthError: { message: string, status?: number, code?: string }
  // error.code === 'network_error' → request never reached the service (status 0)
}

Error messages use Supabase's English phrasing (e.g. Token has expired or is invalid, Signups not allowed), so regex-based error translation written for Supabase keeps working.

Browser client

Setup

import { createAuthClient } from '@deepkey/sdk'

export const deepkey = createAuthClient(import.meta.env.VITE_DEEPKEY_PUB_KEY) // bt_pub_...
// deepkey.auth is the supabase-js-shaped auth interface

Create one client per app (module singleton). On construction the client:

  1. Synchronously, before anything else can read the URL, removes every token- or error-bearing fragment (#access_token=…, #error=…, complete or not) and any DeepKey code (?code=bt_ac_…) from the address bar. Other query parameters, a code that is not DeepKey's, and unrelated anchors stay.
  2. Completes a sign-in this browser started: with PKCE (default) it exchanges the DeepKey code with the verifier it stored; with flowType: 'implicit' it adopts the fragment only for a pending sign-in of that kind. Either way it saves the session and emits SIGNED_IN. Anything this browser did not ask for is dropped, with a console warning.
  3. Otherwise restores the persisted session from localStorage (refreshing it first if expired).
  4. Starts a background refresh timer (checks every 30s, refreshes when the access token is within 90s of expiry).

An empty key does not throw at construction — failures surface on the first call.

The constant DEEPKEY_API_URL exports the hosted origin for building your own URLs (for example the JWKS endpoint).

Options (all optional)

createAuthClient(publishableKey, {
  persistSession: true,       // persist to storage (default true)
  autoRefreshToken: true,     // background refresh + refresh-on-demand (default true)
  detectSessionInUrl: true,   // complete sign-ins from the redirect URL (default true)
  flowType: 'pkce',           // 'pkce' (default) or 'implicit' — how magic links and OAuth return
  storageKey: 'custom-key',   // default: 'bt-<project-slug>-auth-token' (derived from the publishable key)
  storage: customAdapter,     // { getItem, setItem, removeItem } — default: localStorage, in-memory fallback
  fetch: customFetch,         // custom fetch implementation
})

Email OTP / magic link

// 1. Send the email (contains BOTH a 6-digit code and a magic link)
const { error } = await deepkey.auth.signInWithOtp({
  email: '[email protected]',
  options: {
    emailRedirectTo: window.location.origin, // where the magic link lands (must be allow-listed)
    shouldCreateUser: true,                  // set false to reject emails without an account
  },
})

// 2a. User types the 6-digit code → verify it
const { data, error } = await deepkey.auth.verifyOtp({
  email: '[email protected]',
  token: '123456',
  type: 'email',
})
// data.session / data.user are set on success; SIGNED_IN is emitted

// 2b. OR the user clicks the magic link → redirected to emailRedirectTo with
// ?code=… in the URL; the client exchanges it automatically on load
// (detectSessionInUrl) and emits SIGNED_IN. No code needed. The link signs in only
// in the browser that called signInWithOtp; elsewhere, type the 6-digit code.

OAuth (Google, X)

await deepkey.auth.signInWithOAuth({
  provider: 'google', // or 'x'
  options: {
    redirectTo: window.location.origin,  // must be allow-listed
    // scopes: 'extra scopes',
    // queryParams: { prompt: 'select_account' },
    // skipBrowserRedirect: true,        // don't navigate; just return data.url
  },
})
// Performs a full-page redirect to the provider. After consent DeepKey
// redirects back to redirectTo with ?code=… — exchanged automatically on load,
// SIGNED_IN is emitted.

signInWithOAuth resolves { data: { provider, url: null }, error } when it cannot start the flow, for example pkce_unsupported on a plain-HTTP origin (see below).

PKCE and the implicit flow

With the default flowType: 'pkce', signInWithOtp and signInWithOAuth store a fresh code verifier under <storageKey>-code-verifier and send only its S256 challenge (code_challenge, code_challenge_method=s256). DeepKey returns ?code=… to your redirect URL, and the client trades it for a session with POST /auth/v1/token?grant_type=pkce. A code delivered to another browser, or a link someone else sent you, cannot be exchanged without that verifier, and token fragments (#access_token=…) are never adopted in this mode.

// With detectSessionInUrl (the default) the landing page's code is exchanged for you; calling
// this with the same code returns that exchange's result. With detectSessionInUrl: false,
// read the code yourself before anything rewrites the URL:
const code = new URLSearchParams(window.location.search).get('code')
if (code?.startsWith('bt_ac_')) {
  const { data, error } = await deepkey.auth.exchangeCodeForSession(code)
  // error.code === 'pkce_code_verifier_not_found' → started in another browser, or storage was cleared
}
  • Only DeepKey codes (bt_ac_…) are exchanged or removed; a ?code= that belongs to your app is never touched. The client whose project holds a verifier takes the code out of the URL when it is constructed and exchanges it, so several DeepKey clients for different projects can share a page in any order. A DeepKey code that no client on the page can exchange (the link was opened in another browser than the one that requested it) is removed once the current task and the client's initialization are over, because the requester could still exchange it, and a warning is logged.
  • An exchange that got no answer keeps the verifier; exchangeCodeForSession(code) with the landing code posts it again. An answered exchange is final, and exchangeCodeForSession(code) then returns its result.
  • The verifier is a single slot, as in supabase-js: starting a second sign-in replaces the first one's verifier, so only the latest sign-in can complete. Any answer DeepKey gives to an exchange spends the verifier; only an exchange that got no answer keeps it for a retry.
  • PKCE needs crypto.subtle, which browsers expose only on HTTPS and localhost. Elsewhere signInWithOAuth fails with pkce_unsupported, and signInWithOtp still sends the e-mail without a challenge: the code works, the link does not sign in.

flowType: 'implicit' keeps the fragment flow with a gate: the client adopts a fragment only while this browser has a sign-in of the same kind pending (signInWithOAuth → type=oauth within 10 minutes, signInWithOtp → type=magiclink within the e-mail code's lifetime, one hour by default), and any landing, adopted or not, clears every pending sign-in. The fragment's type is chosen by whoever built the link and nothing in the fragment binds it to this browser's sign-in, so this only narrows login CSRF to a timing window: a forged link opened while a sign-in is pending is still adopted. PKCE closes it; use the implicit flow only where PKCE cannot run (origins without crypto.subtle).

Auth state (drive your UI from this)

const { data: { subscription } } = deepkey.auth.onAuthStateChange((event, session) => {
  // event: 'INITIAL_SESSION' | 'SIGNED_IN' | 'SIGNED_OUT' | 'TOKEN_REFRESHED'
  // session: Session | null
})
subscription.unsubscribe()
  • Every subscriber receives INITIAL_SESSION (asynchronously) once initialization settles — use it to resolve your app's loading state. session is null if the user is signed out.
  • TOKEN_REFRESHED fires on background refreshes and when another tab refreshes the same user's session.
  • SIGNED_IN also fires when another tab signs in, or stores a session of a different user (account switch); reset per-user state on it.
  • SIGNED_OUT fires on signOut(), when DeepKey rejects the refresh token, and when another tab signs out.
  • Sessions sync across tabs automatically (via storage events), and tabs take turns refreshing: one tab rotates the refresh token while the others wait and adopt its result.

Session access

// Current session — read locally, refreshed first when the access token is about to expire
// (then it waits: usually well under a second, at worst about 9 s for a round plus up to 20 s
// for another tab's lock). Use it for tokens:
const { data: { session }, error } = await deepkey.auth.getSession()
const accessToken = session?.access_token   // send as Authorization: Bearer <token> to YOUR api
// error with a session: the refresh is failing (outage, rate limit) and the token may be expired

// Authoritative user check (validates against DeepKey):
const { data: { user }, error } = await deepkey.auth.getUser()

// Manual refresh (rarely needed — auto-refresh covers normal usage):
await deepkey.auth.refreshSession()

// Sign out: clears local state and emits SIGNED_OUT at once, then revokes the session on
// DeepKey (refreshing an expired access token first so the logout is accepted):
await deepkey.auth.signOut()

signOut() never leaves the tab signed in: local state goes first, whatever the network does. The revocation that follows is bounded (a refresh round of at most about 9 seconds when the access token has expired, then a logout request of at most 10) and best-effort: with no network the refresh token stays valid on DeepKey until its idle lifetime ends. A sign-in (verifyOtp, exchangeCodeForSession) that completes after signOut() is discarded and resolves session_missing. Every request the client makes is bounded: 10 seconds, 3 for a refresh.

getSession() returns { session: null } (with error: null) when signed out or when DeepKey refused the refresh token (refresh_token_not_found, refresh_token_already_used, session_not_found, session_expired, user_banned, invalid_api_key, project_not_found). Any other failure — no answer, a timeout, a 5xx, a 429, or a 4xx without one of those codes (a WAF's 403, a misrouted 404) — keeps the session, and getSession() returns it together with that error (or refresh_deferred / refresh_lock_timeout, below): the access token may be expired, and your app can tell that refreshing is failing. A 4xx that is not one of DeepKey's codes keeps the session for as long as it lasts, so a misconfigured proxy that always answers 403 keeps an expired session around indefinitely; act on the error if that matters to you.

The DeepKey API answers a rotated refresh token presented within 10 seconds of the rotation with the same new token, every time, and ends the session if it arrives later. So one refresh token is presented in a round: at once, then 1, 3 and 5 seconds after the round started (plus up to 0.5 s of jitter), on that schedule even while earlier requests are still waiting, each request cut off after 3 seconds, and none sent more than 5.5 seconds after the first, which leaves about 4.5 seconds for the last request to reach DeepKey, proxy queues included. A send that comes due late (a throttled background tab) is skipped. A Retry-After on a 429 is honoured inside the round, or postpones the next round when it is longer. When a round ends without an answer the session is kept, refreshSession() reports the failure, the token waits for its next round (refresh_deferred meanwhile), and that round starts at the following 30-second tick at the earliest.

One case cannot be avoided: if DeepKey did rotate the token but every answer inside the round was lost, the next round presents a token DeepKey has already replaced, outside the window, and DeepKey ends the session (refresh_token_already_used, then SIGNED_OUT). The client cannot tell a lost rotation from a request that never arrived; it only makes sure that a round in which any answer gets through recovers, and that every request of a round can still reach DeepKey inside the window. This relies on the repeatable grace window of the current DeepKey API; a server whose grace answer is single-use would end the session on the second presentation inside a round.

Refresh-specific error codes, returned next to the kept session:

| error.code | Meaning | |---|---| | network_error (status 0) | No answer, or a request cut off by its timeout. | | refresh_deferred | This token's round ended without an answer moments ago; it is presented again in the next round (at the following tick). Nothing was sent. | | refresh_lock_timeout | Another tab held the refresh lock for 20 seconds. Nothing was sent. |

React integration pattern

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [session, setSession] = useState<Session | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const { data: { subscription } } = deepkey.auth.onAuthStateChange((_event, session) => {
      setSession(session)
      setLoading(false) // INITIAL_SESSION always arrives, even when signed out
    })
    return () => subscription.unsubscribe()
  }, [])

  if (loading) return <Spinner />
  return <AuthContext.Provider value={{ session }}>{children}</AuthContext.Provider>
}

Calling your own backend

DeepKey authenticates users; your API authorizes them. Attach the access token to your requests:

const { data: { session } } = await deepkey.auth.getSession()
await fetch('/api/things', {
  headers: { Authorization: `Bearer ${session?.access_token}` },
})

Provider connections (durable provider access)

Login discards provider tokens. When your product needs to act on a provider on the user's behalf (for example manage several X accounts), the signed-in user connects accounts. DeepKey keeps the provider refresh token; browsers only see metadata and your server receives short-lived provider access tokens.

// Browser — the user is already signed in (any method: email, Google, X).
await deepkey.connections.connect({
  provider: 'x',
  options: { redirectTo: `${window.location.origin}/settings` }, // must be allow-listed
})
// Full-page redirect to the provider. DeepKey returns to redirectTo with
// #connection_code=...&provider=x in the fragment (or ?error=access_denied&error_code=...).

// On that page: the client removes connection_code from the address bar as soon as it
// is created and completes it with its session; if the user is not signed in yet, with
// the session of the next sign-in, within five minutes. Await the outcome anywhere:
const { data: { connection }, error: completion } = await deepkey.connections.complete()
// connection: the new ProviderConnection, or null when the page had no code.
// completion?.code === 'connection_session_mismatch' → the code reached another user or
//   session than the one that started the flow; the grant was discarded, connect again.
// completion?.code === 'provider_disabled' → the provider was turned off meanwhile.
// completion?.code === 'bad_connection_code' → spent or expired; connect again.
// completion?.code === 'session_missing' → nobody signed in within five minutes (the
//   lifetime of a pending connection with the default AUTH_CODE_TTL_SECONDS).
// Each request is cut off after ten seconds and a network error is retried once. If the
// first request stored the connection but its response was lost, the SDK finds it through
// connections.list() (a connection of that provider updated in the last two minutes, by this
// device's clock) and returns it.

const { data, error } = await deepkey.connections.list()
// data.connections: [{ id, provider, provider_user_id, identity_data, scope, status, created_at, updated_at }]

await deepkey.connections.revoke(connectionId) // revokes at the provider and deletes the connection
// Server — secret key only.
const { data: token, error } = await deepkey.connections.issueAccessToken(connectionId)
// token: { access_token, token_type: 'bearer', expires_at, scope } — no refresh token, ever.
// error?.code === 'connection_reauthorization_required' → the user must connect again.
// error?.code === 'user_banned' or 'provider_disabled' → no token while the owner is banned
// or the provider is turned off for the project; the connection itself is kept.

const { data } = await deepkey.connections.list({ userId })
await deepkey.connections.revoke(connectionId)

How a connection is tied to the user. connections.connect() sends the current DeepKey session as the bearer, so DeepKey binds the flow to the signed-in user.id and session (both are kept on DeepKey with the single-use OAuth state; nothing from the browser chooses the user). The provider URL works in any browser, so the callback stores nothing: it returns a one-time connection_code, valid for five minutes, in the URL fragment of the browser that finished at the provider. The code is a bearer secret for the session that started the flow: only that user and session can complete it, any other completion is refused and discards the grant, and a code of another project does not exist for your key. Construct the client on the redirectTo page so the code is spent there on landing; every client on the page shares the one code (a client of another project passes it on), and a page that never completes it gets no connection. Do not log or forward the URL fragment of that page. redirectTo loses any fragment of its own, as with every DeepKey redirect, so a hash-routed app should use a real path (/settings) and route from there once the code is taken. On your server, correlate with the same id you get from getUser(jwt):

const { data: { user } } = await deepkey.auth.getUser(jwt)          // your authenticated user
const { data: { connections } } = await deepkey.connections.list({ userId: user!.id })
// Only trust a connection_id from your frontend if it is in this list
// (every item also carries user_id === user.id).

Scopes are a project policy: the adapter's minimum plus the connectScopes configured for the provider on the DeepKey console. They are requested only by connections.connect(), never during login. Each connection's scope reports what the provider actually granted.

Server client (validating tokens in your backend)

import { createServerClient } from '@deepkey/sdk/server'

const deepkey = createServerClient(process.env.DEEPKEY_SECRET_KEY) // bt_secret_...

// In your request handler:
const jwt = request.headers.get('authorization')?.replace(/^Bearer /, '')
const { data: { user }, error } = await deepkey.auth.getUser(jwt)
if (error || !user) return new Response('Unauthorized', { status: 401 })
// user.id is the stable user UUID — key your own tables on it

getUser(jwt) only accepts tokens issued by the project of the key it is called with. A token from another DeepKey project fails with error.status === 401 and error.code === 'invalid_token' ("Token belongs to another project").

Alternative — local JWT verification without a network round-trip: access tokens are ES256 JWTs; public keys are served at https://deepkey.dev/api/auth/v1/.well-known/jwks.json. Every DeepKey project's tokens are signed with the same key, so always pin your project: tokens carry aud: ['authenticated', '<project id>'], and the project id is the one in the dashboard URL (/app/projects/<id>) or in any of your tokens' project_id claim. Verify with jose:

import { DEEPKEY_API_URL } from '@deepkey/sdk/server'
import { createRemoteJWKSet, jwtVerify } from 'jose'

const jwks = createRemoteJWKSet(new URL(`${DEEPKEY_API_URL}/auth/v1/.well-known/jwks.json`))
// The audience check refuses tokens of every other DeepKey project. jose skips the check
// when `audience` is undefined, so a missing project id must stop the verifier.
const projectId = process.env.DEEPKEY_PROJECT_ID
if (!projectId) throw new Error('DEEPKEY_PROJECT_ID is required')
const { payload } = await jwtVerify(jwt, jwks, { audience: projectId })
// payload.sub = user id

Tokens issued before the project audience was introduced carry aud: 'authenticated' only; until they expire (one access-token lifetime), compare payload.project_id with your project id instead of relying on the audience alone.

Use getUser() when you need revocation-awareness (a signed-out session fails immediately); use JWKS verification when latency matters (tokens remain valid until expiry).

User API keys (long-lived credentials for your users)

Issue keys to your own users for CLIs, integrations, and automations that cannot hold a browser session. A key always belongs to one user of your project. These are not your project keys: a user API key looks like bt_ak_…, and every method below runs on your server with the secret key.

import { createServerClient } from '@deepkey/sdk/server'

const deepkey = createServerClient(process.env.DEEPKEY_SECRET_KEY) // bt_secret_...

// Create — `key` is the plaintext. Show it to the user now: it is returned once and can never be read again.
const { data: { apiKey, key }, error } = await deepkey.apiKeys.create({ userId: user.id, name: 'CI deploy' })
// apiKey: { id, user_id, name, last4, last_used_at, revoked_at, created_at }

// List — metadata only (active and revoked), newest first. Never the plaintext.
const { data: { apiKeys } } = await deepkey.apiKeys.list({ userId: user.id })

// Revoke — immediate and idempotent. The key stays listed with `revoked_at` set.
await deepkey.apiKeys.revoke(apiKey.id)

Validating a key is the API-key counterpart of auth.getUser(jwt) and returns the same User:

// In your request handler:
const presented = request.headers.get('x-api-key')
const { data: { user, apiKey }, error } = await deepkey.apiKeys.verify(presented)
if (error || !user) return new Response('Unauthorized', { status: 401 })
// user.id is the same stable UUID you get from getUser(jwt); apiKey tells you which key was used.

| error.code | Meaning | |---|---| | invalid_user_api_key (401) | Unknown, malformed, revoked, or issued by another project. Deliberately indistinguishable. | | user_banned (401) | The key is intact but its owner is banned. | | secret_key_required (403) | The client was created with a publishable key. | | user_not_found (404) | create received a userId that is not a user of this project. | | api_key_not_found (404) | revoke received an id that is not a key of this project. |

verify sends the key in the request body, never in a URL. A successful verify updates last_used_at; a rejected one does not. DeepKey stores only a SHA-256 hash and the last four characters, so a lost key cannot be recovered: revoke it and create a new one. Only trust an apiKey.id sent by your frontend if it appears in apiKeys.list({ userId }) for your authenticated user.

Types (full public surface)

// '@deepkey/sdk'
const DEEPKEY_API_URL: 'https://deepkey.dev/api'
function createAuthClient(publishableKey: string, options?: AuthClientOptions): { auth: Auth; connections: Connections }

class Auth {
  signInWithOtp(credentials: SignInWithOtpCredentials): Promise<AuthResponse<{ user: null; session: null }>>
  verifyOtp(params: VerifyOtpParams): Promise<AuthResponse<{ user: User | null; session: Session | null }>>
  signInWithOAuth(credentials: SignInWithOAuthCredentials): Promise<OAuthResponse>
  exchangeCodeForSession(authCode: string): Promise<AuthResponse<{ session: Session | null; user: User | null }>>
  onAuthStateChange(cb: (event: AuthChangeEvent, session: Session | null) => void): { data: { subscription: Subscription } }
  getSession(): Promise<AuthResponse<{ session: Session | null }>>
  getUser(jwt?: string): Promise<AuthResponse<{ user: User | null }>>
  refreshSession(currentSession?: { refresh_token: string }): Promise<AuthResponse<{ session: Session | null; user: User | null }>>
  signOut(): Promise<{ error: AuthError | null }>
}

class Connections {
  connect(credentials: ConnectProviderCredentials): Promise<AuthResponse<{ provider: string; url: string | null }>>
  complete(connectionCode?: string): Promise<AuthResponse<{ connection: ProviderConnection | null }>>
  list(): Promise<AuthResponse<{ connections: ProviderConnection[] }>>
  revoke(connectionId: string): Promise<{ error: AuthError | null }>
}

interface ConnectProviderCredentials { provider: string; options?: { redirectTo?: string; skipBrowserRedirect?: boolean } }
interface ProviderConnection {
  id: string                    // stable reference for your own tables
  user_id: string               // the DeepKey user that granted it (same id as getUser(jwt).user.id)
  provider: string
  provider_user_id: string      // immutable provider subject
  identity_data: Record<string, unknown>
  scope: string | null          // what the provider granted
  status: 'active' | 'reauthorization_required'
  created_at: string
  updated_at: string
}
interface ProviderAccessToken { access_token: string; token_type: 'bearer'; expires_at: number | null; scope: string | null }

interface AuthClientOptions {
  persistSession?: boolean
  autoRefreshToken?: boolean
  detectSessionInUrl?: boolean
  storageKey?: string
  storage?: StorageAdapter
  fetch?: typeof fetch
  flowType?: AuthFlowType     // default 'pkce'
}
type AuthFlowType = 'pkce' | 'implicit'

type AuthChangeEvent = 'INITIAL_SESSION' | 'SIGNED_IN' | 'SIGNED_OUT' | 'TOKEN_REFRESHED'
interface AuthResponse<T> { data: T; error: AuthError | null }
type OAuthResponse =
  | { data: { provider: string; url: string }; error: null }
  | { data: { provider: string; url: null }; error: AuthError }
class AuthError extends Error { status?: number; code?: string }

interface Session {
  access_token: string      // ES256 JWT
  refresh_token: string     // opaque, single-use (rotates on refresh)
  token_type: 'bearer'
  expires_in: number        // seconds
  expires_at: number        // unix epoch seconds
  user: User
}

interface User {
  id: string                // stable UUID — use as the foreign key in your app
  aud: string
  role: string
  email: string | null
  email_confirmed_at: string | null
  app_metadata: { provider?: string | null; providers?: string[] }
  user_metadata: { name?: string; full_name?: string; avatar_url?: string; picture?: string; email?: string }
  identities?: UserIdentity[]   // one per linked provider (google, x, email, …)
  created_at: string
}

// '@deepkey/sdk/server'
const DEEPKEY_API_URL: 'https://deepkey.dev/api'
function createServerClient(secretKey: string, options?: ServerClientOptions): {
  auth: { getUser(jwt: string): Promise<AuthResponse<{ user: User | null }>> }
  connections: {
    list(params: { userId: string }): Promise<AuthResponse<{ connections: ProviderConnection[] }>>
    issueAccessToken(connectionId: string): Promise<AuthResponse<ProviderAccessToken | null>>
    revoke(connectionId: string): Promise<{ error: AuthError | null }>
  }
  apiKeys: {
    create(params: { userId: string; name?: string }): Promise<AuthResponse<{ apiKey: UserApiKey | null; key: string | null }>>
    list(params: { userId: string }): Promise<AuthResponse<{ apiKeys: UserApiKey[] }>>
    revoke(apiKeyId: string): Promise<{ error: AuthError | null }>
    verify(apiKey: string): Promise<AuthResponse<{ user: User | null; apiKey: UserApiKey | null }>>
  }
}
interface ServerClientOptions { fetch?: typeof fetch }

interface UserApiKey {
  id: string
  user_id: string             // same id as User.id
  name: string | null
  last4: string               // last four characters of the plaintext, for display
  last_used_at: string | null // last successful apiKeys.verify
  revoked_at: string | null   // set once revoked; a revoked key never verifies again
  created_at: string
}

HTTP endpoints used (for debugging)

All calls send the API key as the apikey header against https://deepkey.dev/api/auth/v1:

| Method call | HTTP request | |---|---| | signInWithOtp | POST /otp (with code_challenge, code_challenge_method: 's256' under PKCE) | | verifyOtp | POST /verify | | signInWithOAuth | navigate to GET /authorize?provider=…&apikey=…&code_challenge=…&code_challenge_method=s256 | | (magic link click) | GET /verify?token=… → 302 to your app with ?code=… (PKCE) or #access_token=… (implicit) | | exchangeCodeForSession / PKCE landing | POST /token?grant_type=pkce with { auth_code, code_verifier } | | token refresh | POST /token?grant_type=refresh_token | | getUser | GET /user (with Authorization: Bearer) | | signOut | POST /logout | | connections.connect | POST /connections/authorize (bearer) → navigate to data.url; provider returns to redirectTo#connection_code=… | | connections.complete (also on load when the fragment has connection_code) | POST /connections/complete with { connection_code } (bearer) | | connections.list | GET /connections (bearer) or GET /connections?user_id=… (secret key) | | server connections.issueAccessToken | POST /connections/:id/token (secret key) | | connections.revoke | DELETE /connections/:id | | server apiKeys.create | POST /api-keys with { user_id, name? } (secret key) | | server apiKeys.list | GET /api-keys?user_id=… (secret key) | | server apiKeys.verify | POST /api-keys/verify with { api_key } (secret key) | | server apiKeys.revoke | DELETE /api-keys/:id (secret key) |

Gotchas & troubleshooting

  • Login redirects to the wrong URL → your redirectTo/emailRedirectTo is not on the project's redirect allow-list; the service silently fell back to the Site URL. Add the URL (with wildcards if needed) in the console.
  • Session not restored after redirect → DeepKey returns ?code=… (PKCE) or #access_token=… (implicit). Make sure the client is constructed on the page the user lands on, before anything rewrites location, that detectSessionInUrl isn't disabled, and that the sign-in started in this browser: a code needs the verifier stored there, and an implicit fragment needs a pending sign-in. Both are taken out of the address bar as soon as the client is constructed, and the console explains anything it dropped.
  • getUser(jwt) returns Token belongs to another project → the token was issued by a different DeepKey project than the key you call with (for example a session left over from another environment). Sign the user in again against this project.
  • Using the secret key in the browser (or the publishable key on the server) → key types are not interchangeable: bt_pub_… for the browser client, bt_secret_… for createServerClient.
  • apiKeys.* returns secret_key_required → user API keys are server-only. Call them from createServerClient(bt_secret_…), never from the browser client.
  • verifyOtp keeps answering Token has expired or is invalid for the right code → only the code from the latest email can be typed (requesting a code supersedes the earlier codes), each code allows 5 attempts, and an address allows 20 wrong codes per 24 hours across all its codes. Type the code from the latest email, or click the magic link of one of your five most recent emails: links stay valid for an hour, once, and the wrong-code limit does not apply to them (a custom template without {{magic_link}} removes that way out). signInWithOtp failing with over_request_rate_limit ("try again in a moment") means another request for the same address was in progress: retry right away, unlike the cooldown's over_email_send_rate_limit. A banned user gets user_banned (403) from verifyOtp, and the magic link redirects with error_code=user_banned.
  • getUser() returns Auth session missing! (code: 'session_missing') → no active session and no jwt argument was passed.
  • error.status === 429 → a rate limit. code: 'over_email_send_rate_limit' (Email rate limit exceeded) means too many codes were sent to one inbox, or too many new sign-ups hit the project; code: 'over_request_rate_limit' (Request rate limit reached) means too many requests or failed attempts from one network, or too many wrong codes for one address. Both messages contain rate limit, like the resend cooldown. Ask the user to wait and try again; don't retry in a loop.
  • SSR → the default storage falls back to in-memory when localStorage is unavailable, so importing the client on the server is safe, but sessions only persist in the browser. Do sign-in flows client-side; validate tokens server-side with @deepkey/sdk/server.
  • Multiple tabs → handled: sessions sync via storage events, and only one tab refreshes at a time: Web Locks where the browser has them, otherwise a 15-second lease in storage that the holder renews while it retries. A holder lets go within about 9 seconds (its round sends nothing after 5.5 and each request times out after 3); a waiting tab gives up after 20, keeping its session (refresh_lock_timeout). Once inside, a tab adopts the session another tab stored, or finds the token's round past its window and presents nothing. A refusal that ends the session on DeepKey is never papered over by adopting a newer token of that same session from another tab. Two limits remain: storage has no compare-and-set, so two tabs without Web Locks can rarely both win the lease (both then present inside the same round, and DeepKey's repeatable grace gives both the same new token); and a custom per-tab storage such as sessionStorage shares nothing between tabs, so only Web Locks coordinate them (a duplicated tab starts with a copy of the session).
  • publishable_key_required → the browser client was created with a secret credential (bt_secret_…, or another server-only bt_* value such as a bt_ak_… user API key), in any runtime: pages, workers, React Native and server-side rendering alike. Every auth method resolves this error and connections.* resolve session_missing; nothing is sent. Use createServerClient on servers, and rotate the secret if it was ever shipped to clients.

Upgrading from 1.x

Version 2.0.0 makes sign-in redirects safe by default. Most apps need no code change:

  • 2.0.0 needs the current DeepKey API. Its refresh retries rely on the API's repeatable grace window (every presentation of a rotated token inside 10 seconds gets the same new token), and its magic links on the API's magic-link PKCE. The hosted API ships both before 2.0.0 is released; against an older API a lost refresh answer can end the session, and a magic link lands with a fragment the client refuses (the six-digit code always works).
  • PKCE is the default. Magic links and OAuth return ?code=… and the client exchanges it on load. A magic link now signs in only in the browser that requested it, in either flow; the six-digit code works anywhere. Set flowType: 'implicit' to keep fragments.
  • Fragments are adopted only for a sign-in this browser started. Code that built sessions from hand-made or cross-device #access_token=… links stops working; that is the login-CSRF fix.
  • signInWithOAuth can fail: its type is OAuthResponse, so data.url is string | null — check error first.
  • SIGNED_IN fires for cross-tab sign-ins and account switches that 1.x reported as TOKEN_REFRESHED.
  • Only DeepKey's refusal codes sign out on refresh; network errors, timeouts, 5xx, 429 and foreign 4xx answers keep the session, retried inside DeepKey's grace window, and getSession() now returns such a failure in error next to the kept session (1.x returned error: null).
  • signOut() signs out locally first, emits SIGNED_OUT before any request, and then revokes the session even when the access token has expired.
  • Secret keys are refused by the browser client in every runtime, with publishable_key_required.
  • Clients created with a key that is not a publishable key (rare) store their session under a slot of their own, bt-auth-token-<hash>, instead of the shared bt-auth-token: such sessions are signed out once.

Compatibility with supabase-js

Supported and drop-in: signInWithOtp, verifyOtp, signInWithOAuth, exchangeCodeForSession, onAuthStateChange, getSession, getUser, refreshSession, signOut, the { data, error } envelopes, the event lifecycle, localStorage persistence, flowType: 'pkce' | 'implicit' with ?code= and fragment handling, and the lock:<storageKey> Web Lock around refreshes. Deliberate differences: PKCE is the default (as in @supabase/ssr), the implicit flow adopts only fragments of a sign-in pending in this browser, only DeepKey's refusal codes end the session on refresh (supabase-js keeps it only for network errors and 502/503/504), retries stay inside DeepKey's grace window, and only bt_ac_ codes are read from the URL. Not implemented: password auth, phone auth, updateUser, and admin APIs.