@deepkey/sdk
v2.0.0
Published
DeepKey authentication SDK for the browser and server — a supabase-js compatible auth surface
Maintainers
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/sdkCore 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 (
apikeyheader). - 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 withflowType: 'implicit'. - Redirect allow-list — every
redirectTo/emailRedirectToURL 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.comincluded — 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.appand*.github.ioare rejected whileapp.vercel.appis 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 interfaceCreate one client per app (module singleton). On construction the client:
- 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, acodethat is not DeepKey's, and unrelated anchors stay. - 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 emitsSIGNED_IN. Anything this browser did not ask for is dropped, with a console warning. - Otherwise restores the persisted session from
localStorage(refreshing it first if expired). - 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, andexchangeCodeForSession(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 andlocalhost. ElsewheresignInWithOAuthfails withpkce_unsupported, andsignInWithOtpstill 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.sessionisnullif the user is signed out. TOKEN_REFRESHEDfires on background refreshes and when another tab refreshes the same user's session.SIGNED_INalso fires when another tab signs in, or stores a session of a different user (account switch); reset per-user state on it.SIGNED_OUTfires onsignOut(), when DeepKey rejects the refresh token, and when another tab signs out.- Sessions sync across tabs automatically (via
storageevents), 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 itgetUser(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 idTokens 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/emailRedirectTois 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 rewriteslocation, thatdetectSessionInUrlisn'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)returnsToken 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_…forcreateServerClient. apiKeys.*returnssecret_key_required→ user API keys are server-only. Call them fromcreateServerClient(bt_secret_…), never from the browser client.verifyOtpkeeps answeringToken has expired or is invalidfor 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).signInWithOtpfailing withover_request_rate_limit("try again in a moment") means another request for the same address was in progress: retry right away, unlike the cooldown'sover_email_send_rate_limit. A banned user getsuser_banned(403) fromverifyOtp, and the magic link redirects witherror_code=user_banned.getUser()returnsAuth session missing!(code: 'session_missing') → no active session and nojwtargument 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 containrate 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
localStorageis 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-tabstoragesuch assessionStorageshares 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-onlybt_*value such as abt_ak_…user API key), in any runtime: pages, workers, React Native and server-side rendering alike. Everyauthmethod resolves this error andconnections.*resolvesession_missing; nothing is sent. UsecreateServerClienton 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. SetflowType: '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. signInWithOAuthcan fail: its type isOAuthResponse, sodata.urlisstring | null— checkerrorfirst.SIGNED_INfires for cross-tab sign-ins and account switches that 1.x reported asTOKEN_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 inerrornext to the kept session (1.x returnederror: null). signOut()signs out locally first, emitsSIGNED_OUTbefore 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 sharedbt-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.
