Use OpenKey as an OAuth 2.1 provider for your application
OpenKey implements an OAuth 2.1 provider, allowing your application to authenticate users with their OpenKey account and request signing operations using standard OAuth flows. This is the recommended integration path for server-side applications or apps that need token-based access.
OpenKey uses OAuth 2.1 with PKCE (Proof Key for Code Exchange), which is secure for both server-side and single-page applications without requiring a client secret for public clients.
Before using OAuth, you need to register your application as an OAuth client. If you are self-hosting, use the CLI tool. Otherwise, contact your OpenKey administrator.
Server-side web apps that can securely store a secret
Use spa for most clients. Unless your app is a server-side web application that can keep a secret out of the browser, spa with PKCE provides sufficient security. There is no benefit to using web type if your secret would be exposed in client-side code.
Redirect the user to OpenKey’s authorization endpoint with PKCE parameters.
// PKCE helpersfunction generateCodeVerifier(): string { const array = new Uint8Array(32); crypto.getRandomValues(array); return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');}async function generateCodeChallenge(verifier: string): Promise<string> { const data = new TextEncoder().encode(verifier); const hash = await crypto.subtle.digest('SHA-256', data); return btoa(String.fromCharCode(...new Uint8Array(hash))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');}function generateRandomString(length: number): string { const array = new Uint8Array(length); crypto.getRandomValues(array); return Array.from(array, b => b.toString(16).padStart(2, '0')).join('').slice(0, length);}// Generate PKCE valuesconst codeVerifier = generateCodeVerifier();const codeChallenge = await generateCodeChallenge(codeVerifier);const state = generateRandomString(16);// Store verifier and state for latersessionStorage.setItem('pkce_verifier', codeVerifier);sessionStorage.setItem('oauth_state', state);// Build authorization URLconst authUrl = new URL('https://api.openkey.so/api/auth/oauth2/authorize');authUrl.searchParams.set('client_id', 'ok_your_client_id');authUrl.searchParams.set('redirect_uri', 'https://myapp.com/callback');authUrl.searchParams.set('response_type', 'code');authUrl.searchParams.set('scope', 'openid');authUrl.searchParams.set('state', state);authUrl.searchParams.set('code_challenge', codeChallenge);authUrl.searchParams.set('code_challenge_method', 'S256');// Redirect to OpenKeywindow.location.href = authUrl.toString();
2
User authenticates and consents
OpenKey redirects the user to its login page. The user authenticates with their passkey and is shown a consent screen for your application. After approval, OpenKey redirects back to your redirect_uri with an authorization code.
If you are using a web (confidential) client and implementing your own registration endpoint, be aware that the client secret is hashed with SHA-256 and stored as base64url encoding (not hex, not standard base64). If your hash function outputs hex or standard base64, the secret will not match during token exchange and authentication will fail silently.
// Correct: base64url encoding (what better-auth expects)const hash = crypto.createHash('sha256').update(secret).digest('base64url');// Wrong: hex encodingconst hash = crypto.createHash('sha256').update(secret).digest('hex');// Wrong: standard base64 (uses + and / instead of - and _)const hash = crypto.createHash('sha256').update(secret).digest('base64');
Token endpoint authentication differs by client type
The way you send credentials to the /api/auth/oauth2/token endpoint depends on your client type. Using the wrong method will result in a rejected request.spa and native clients (public): Send client_id in the POST body. Do not send an Authorization header.
// Public client (spa/native) - client_id in the bodyconst response = await fetch('https://api.openkey.so/api/auth/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authorizationCode, redirect_uri: 'https://myapp.com/callback', client_id: 'ok_your_client_id', // In the body code_verifier: codeVerifier, }),});
web clients (confidential): Send credentials via the Authorization: Basic header. Do NOT put the secret in the POST body.
OpenKey implements OAuth 2.1, which requires PKCE (Proof Key for Code Exchange) for all client types, including confidential web clients. Omitting code_challenge from the authorization request or code_verifier from the token exchange will cause the request to fail.
State parameter must be verified
Always generate a cryptographically random state value, store it in a cookie or server-side session, and verify it matches the state returned on the callback. This prevents CSRF attacks.
// Generate and store state before redirectconst state = crypto.randomBytes(16).toString('hex');// Store in a cookie or session — NOT in localStorage (vulnerable to XSS)// Verify on callbackif (callbackState !== storedState) { throw new Error('State mismatch - possible CSRF attack');}
SvelteKit: Do not generate PKCE state in load functions
If you are building with SvelteKit, do not generate PKCE parameters (code_verifier, state) inside +page.server.ts load functions. These load functions can execute multiple times during SSR and hydration, which means the values stored in the cookie and the values used in the redirect URL can fall out of sync.Instead, use a +server.ts endpoint that runs exactly once per request:
Set these environment variables in your client application:
# OpenKey API base URLAPI_URL=http://localhost:3001# Client ID from oauth:register or the admin APIOAUTH_CLIENT_ID=ok_...# Only for web (confidential) clients — do NOT set this for spa/native clients# OAUTH_CLIENT_SECRET=oks_...
Never expose OAUTH_CLIENT_SECRET in client-side code. If your app runs in the browser, use spa type (no secret needed). The secret is only for server-side web clients where it stays on your backend.