Getting OAuth2 Credentials
Before you can start the flow, register your application to receive aclient_id and client_secret.
Go to Settings → Developer → OAuth2 Clients in your Bkmark account and create a new client. You will need to provide:
- A display name for your application
- One or more allowed redirect URIs (exact match required)
- The scopes your application plans to request
The Authorization Flow
PKCE binds the authorization request to the token exchange, preventing code interception attacks. Generate a fresh pair for every authorization attempt.
import crypto from 'node:crypto';
// Step 1: Generate a cryptographically random verifier (32 bytes → 43-char base64url string)
const codeVerifier = crypto.randomBytes(32).toString('base64url');
// Step 2: Hash the verifier with SHA-256 to produce the challenge
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Store codeVerifier in your server-side session — you'll need it in Step 4
// Send codeChallenge (not the verifier) in the authorization request
console.log({ codeVerifier, codeChallenge });
// {
// codeVerifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
// codeChallenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'
// }
const state = crypto.randomBytes(16).toString('hex');
// Store state in your session alongside the codeVerifier
Redirect the user’s browser to
https://api.bkmark.it/oauth/authorize with the following query parameters:GET https://api.bkmark.it/oauth/authorize
?response_type=code
&client_id=bk_your_client_id
&redirect_uri=https://yourapp.com/oauth/callback
&scope=bookmarks:read%20bookmarks:write%20tags:read
&state=a8f3bc19d02e47c1
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
Bkmark displays a consent screen that lists exactly which permissions your app is requesting. The user can approve or deny.
The URI Bkmark redirects to after the user decides. Must exactly match one of the redirect URIs you registered.
Space-separated list of permission scopes. See the Scopes reference for all available values.
A random, unguessable string you generate per-request. Bkmark echoes it back in the callback. Verify it matches before continuing.
The SHA-256 hash of your
code_verifier, encoded as base64url. Required for public clients; strongly recommended for all clients.Always verify the
state parameter. Compare the value in the callback against the value you stored in the session before Step 2. If they don’t match, abort the flow — this is a CSRF indicator. Never proceed with a mismatched or missing state.// Example Express.js callback handler
app.get('/oauth/callback', async (req, res) => {
const { code, state, error } = req.query;
// 1. Verify state to prevent CSRF
if (state !== req.session.oauthState) {
return res.status(403).send('State mismatch — possible CSRF attack');
}
// 2. Handle user denial
if (error === 'access_denied') {
return res.redirect('/connect?denied=1');
}
// 3. Exchange code for tokens (see next step)
const tokens = await exchangeCodeForTokens(code, req.session.codeVerifier);
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
res.redirect('/dashboard');
});
The authorization code is single-use and expires quickly. Exchange it for an access token and refresh token by making a server-side
POST request:curl -X POST https://api.bkmark.it/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=SplxlOBeZQQYbYS6WxSbIA" \
-d "redirect_uri=https://yourapp.com/oauth/callback" \
-d "client_id=bk_your_client_id" \
-d "client_secret=your_client_secret" \
-d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
The original random verifier you generated in Step 1. Required if you sent a
code_challenge in the authorization request.{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
"scope": "bookmarks:read bookmarks:write tags:read"
}
The scopes actually granted. May be a subset of what you requested if the user partially approved. Always check this field before making API calls.
// Full token exchange helper
async function exchangeCodeForTokens(code, codeVerifier) {
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://yourapp.com/oauth/callback',
client_id: process.env.BKMARK_CLIENT_ID,
client_secret: process.env.BKMARK_CLIENT_SECRET,
code_verifier: codeVerifier,
});
const response = await fetch('https://api.bkmark.it/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!response.ok) {
const err = await response.json();
throw new Error(`Token exchange failed: ${err.error} — ${err.error_description}`);
}
return response.json();
}
curl https://api.bkmark.it/api/v1/bookmarks \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
async function getBookmarks(accessToken) {
const response = await fetch('https://api.bkmark.it/api/v1/bookmarks', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (response.status === 401) {
// Token expired — trigger the refresh flow
throw new Error('TOKEN_EXPIRED');
}
return response.json();
}
Access tokens expire after 1 hour. Use the refresh token to obtain a new pair without requiring the user to re-authorize.
curl -X POST https://api.bkmark.it/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4..." \
-d "client_id=bk_your_client_id" \
-d "client_secret=your_client_secret"
The refresh response contains a new refresh token that replaces the old one. The old refresh token is immediately revoked. Always persist the new refresh token to storage — if you reuse the old one, subsequent refreshes will fail.
The response has the same shape as the initial token exchange response. Store both the new
access_token and the new refresh_token.async function refreshTokens(refreshToken) {
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.BKMARK_CLIENT_ID,
client_secret: process.env.BKMARK_CLIENT_SECRET,
});
const response = await fetch('https://api.bkmark.it/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!response.ok) {
// Refresh token expired or revoked — user must re-authorize
const err = await response.json();
throw new Error(`Refresh failed: ${err.error}`);
}
const tokens = await response.json();
// Persist tokens.access_token AND tokens.refresh_token
return tokens;
}
Revoking Tokens
When a user disconnects your application, revoke their refresh token so Bkmark can clean up the session and the token can no longer be used:The access token or refresh token to revoke.
access_token or refresh_token. Optional but helps Bkmark look up the token faster.Your application’s client ID.
Your application’s client secret.
200 OK per RFC 7009, even if the token was already expired or revoked.
Token Introspection
To check whether a token is still active — for example, before making a batch of API calls — query the introspection endpoint:true if the token is valid and has not expired or been revoked.Space-separated scopes granted to this token.
The client ID that was issued this token.
The Bkmark user ID this token acts on behalf of.
UTC timestamp when the access token expires.
Security Checklist
- Never expose your
client_secretin client-side JavaScript, mobile app binaries, or source control. - Always validate
statein the callback before processing the authorization code. - Use PKCE for SPAs and mobile apps, which cannot keep a
client_secretconfidential. - Store tokens securely — use encrypted storage or a secrets manager, never cookies without
HttpOnly/Secureflags orlocalStorage. - Implement automatic token refresh so users aren’t interrupted when access tokens expire.
- Revoke tokens on disconnect — always call
POST /oauth/revokewhen a user unlinks your app. - Request only the scopes you need — see the Scopes reference for guidance on minimal scope sets.