Skip to main content
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.

How It Works

OpenKey OAuth 2.1 authorization code flow
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.

Register an OAuth Client

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.

CLI Registration

The fastest way to register a client when self-hosting:
bun run oauth:register --name "My App" --type spa --redirect-uri "http://localhost:3000/callback"
This outputs the client_id (and client_secret for web type clients) that you will use in the OAuth flow.

API Registration

You can also register clients via the admin API:
curl -X POST https://api.openkey.so/api/admin/oauth/clients \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_ADMIN_API_KEY" \
  -d '{
    "name": "My Application",
    "redirectUris": ["https://myapp.com/callback"],
    "uri": "https://myapp.com",
    "type": "spa"
  }'
Response:
{
  "success": true,
  "client": {
    "id": "abc123",
    "clientId": "ok_1a2b3c4d5e6f7g8h9i0j...",
    "name": "My Application",
    "redirectUris": ["https://myapp.com/callback"],
    "uri": "https://myapp.com",
    "type": "spa",
    "public": true,
    "createdAt": "2026-01-15T10:30:00.000Z"
  }
}
For web (confidential) clients, the response also includes a clientSecret field:
{
  "success": true,
  "client": {
    "id": "abc123",
    "clientId": "ok_1a2b3c4d5e6f7g8h9i0j...",
    "clientSecret": "oks_9z8y7x6w5v4u3t2s1r0q...",
    "name": "My Application",
    "type": "web",
    "public": false
  }
}
The clientSecret is only returned once at creation time. Store it securely. You cannot retrieve it again.

Client Types

Choose the client type based on where your application runs:
TypeclientSecrettokenEndpointAuthMethodUse Case
spaNo (public)noneBrowser apps, single-page applications, first-party dashboards
nativeNo (public)noneMobile or desktop apps
webYes (confidential)client_secret_basicServer-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.

Authorization Code Flow

1

Build the authorization URL

Redirect the user to OpenKey’s authorization endpoint with PKCE parameters.
// PKCE helpers
function 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 values
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateRandomString(16);

// Store verifier and state for later
sessionStorage.setItem('pkce_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);

// Build authorization URL
const 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 OpenKey
window.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.
https://myapp.com/callback?code=AUTH_CODE&state=ORIGINAL_STATE
3

Verify state and exchange the code

Verify the state parameter matches what you sent, then exchange the authorization code for tokens.
// Verify state
const params = new URLSearchParams(window.location.search);
const returnedState = params.get('state');
const savedState = sessionStorage.getItem('oauth_state');

if (returnedState !== savedState) {
  throw new Error('State mismatch - possible CSRF attack');
}

// Exchange code for tokens
const 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: params.get('code'),
    redirect_uri: 'https://myapp.com/callback',
    client_id: 'ok_your_client_id',
    code_verifier: sessionStorage.getItem('pkce_verifier'),
  }),
});

const tokens = await response.json();
4

Use the tokens

The token response contains:
{
  "access_token": "eyJhbG...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "id_token": "eyJhbG...",
  "scope": "openid",
  "refresh_token": "rt_abc123..."
}

Token Lifetimes

TokenLifetime
Access token1 hour
ID token1 hour
Refresh token7 days

Using the SDK

The OpenKey SDK provides helper methods that handle PKCE generation, state management, and token exchange automatically.
import { OpenKey } from '@openkey/sdk';

const openkey = new OpenKey({ appName: 'My App' });

// Start OAuth flow (opens popup)
const { code, state } = await openkey.oauth.connect({
  clientId: 'ok_your_client_id',
  redirectUri: 'https://myapp.com/callback',
});

// Exchange code for tokens
const tokens = await openkey.oauth.exchangeCode(code, {
  clientId: 'ok_your_client_id',
  redirectUri: 'https://myapp.com/callback',
});

console.log('Access token:', tokens.access_token);

Handling the Callback

If using redirect mode instead of popup, parse the callback URL on your callback page:
const openkey = new OpenKey();

// Parse the callback URL
const result = openkey.oauth.parseCallback();

if ('error' in result) {
  console.error('OAuth error:', result.error, result.errorDescription);
} else {
  // Verify state
  if (!openkey.oauth.verifyState(result.state)) {
    throw new Error('State mismatch');
  }

  // Exchange code
  const tokens = await openkey.oauth.exchangeCode(result.code, {
    clientId: 'ok_your_client_id',
    redirectUri: 'https://myapp.com/callback',
  });
}

Scopes

Currently, OpenKey supports the following OAuth scope:
ScopeDescription
openidBasic identity verification. Returns user info in the ID token.
Additional scopes for granular signing permissions are planned for future releases.

Managing OAuth Clients

List Clients

curl https://api.openkey.so/api/admin/oauth/clients \
  -H "Authorization: Bearer YOUR_ADMIN_API_KEY"

Update a Client

curl -X PATCH https://api.openkey.so/api/admin/oauth/clients/ok_client_id \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_ADMIN_API_KEY" \
  -d '{
    "name": "Updated App Name",
    "redirectUris": ["https://myapp.com/callback", "https://staging.myapp.com/callback"]
  }'

Disable a Client

curl -X PATCH https://api.openkey.so/api/admin/oauth/clients/ok_client_id \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_ADMIN_API_KEY" \
  -d '{"disabled": true}'

Delete a Client

curl -X DELETE https://api.openkey.so/api/admin/oauth/clients/ok_client_id \
  -H "Authorization: Bearer YOUR_ADMIN_API_KEY"

Server-Side Examples

Most applications should use an spa client. No secret is needed; PKCE handles security.
import express from 'express';
import crypto from 'crypto';

const app = express();
const CLIENT_ID = process.env.OAUTH_CLIENT_ID;
const REDIRECT_URI = 'https://myapp.com/callback';

// Store PKCE verifiers in session (use a real session store in production)
const sessions = new Map();

app.get('/login', (req, res) => {
  const state = crypto.randomBytes(16).toString('hex');
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');

  sessions.set(state, { verifier });

  const authUrl = new URL('https://api.openkey.so/api/auth/oauth2/authorize');
  authUrl.searchParams.set('client_id', CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('scope', 'openid');
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('code_challenge', challenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');

  res.redirect(authUrl.toString());
});

app.get('/callback', async (req, res) => {
  const { code, state } = req.query;
  const session = sessions.get(state);

  if (!session) {
    return res.status(400).send('Invalid state');
  }

  sessions.delete(state);

  // Public client: send client_id in the POST body
  const tokenRes = 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,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: session.verifier,
    }),
  });

  const tokens = await tokenRes.json();
  res.json({ message: 'Authenticated!', tokens });
});

app.listen(3000);

Common Pitfalls

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 encoding
const 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');
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 body
const 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.
// Confidential client (web) - credentials in Authorization header
const credentials = btoa(`${clientId}:${clientSecret}`);
const response = await fetch('https://api.openkey.so/api/auth/oauth2/token', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Authorization': `Basic ${credentials}`,     // In the header
  },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authorizationCode,
    redirect_uri: 'https://myapp.com/callback',
    code_verifier: codeVerifier,
  }),
});
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.
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 redirect
const state = crypto.randomBytes(16).toString('hex');
// Store in a cookie or session — NOT in localStorage (vulnerable to XSS)

// Verify on callback
if (callbackState !== storedState) {
  throw new Error('State mismatch - possible CSRF attack');
}
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:
// src/routes/auth/login/+server.ts
import { redirect } from '@sveltejs/kit';
import crypto from 'crypto';

export async function GET({ cookies }) {
  const state = crypto.randomBytes(16).toString('hex');
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');

  cookies.set('oauth_state', state, { path: '/', httpOnly: true });
  cookies.set('pkce_verifier', verifier, { path: '/', httpOnly: true });

  const authUrl = new URL('https://api.openkey.so/api/auth/oauth2/authorize');
  authUrl.searchParams.set('client_id', CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('scope', 'openid');
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('code_challenge', challenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');

  throw redirect(302, authUrl.toString());
}

Environment Variables

Set these environment variables in your client application:
# OpenKey API base URL
API_URL=http://localhost:3001

# Client ID from oauth:register or the admin API
OAUTH_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.

Next Steps

Widget Integration

Use the widget for a lighter client-side integration.

TinyCloud Integration

Combine OpenKey OAuth with TinyCloud SDK.