Sign in with Bodek
Bodek Accounts is an OAuth 2.0 authorization server with OpenID Connect–style discovery and UserInfo. Add a “Sign in with Bodek” button to any site or app: your users authenticate with their existing Bodek account — including their two-factor method — and you receive a verified identity without ever handling their password.
Issuer https://accounts.bodek.us
Grants authorization_code (+ PKCE S256) · refresh_token
Tokens Opaque bearer access tokens (1 hour), rotating refresh tokens (90 days)
Identity GET https://accounts.bodek.us/userinfo
Access tokens are opaque 64-character hex strings, not JWTs. Validate them with
/userinfo or /introspect
rather than decoding them.
id_token, and the discovery document's jwks_uri is null.
Get the user's identity (sub, name, email…) from /userinfo with the access
token. OIDC client libraries that insist on an ID token must be configured for plain OAuth 2.0
(“userinfo only”) mode. A nonce parameter is accepted on /authorize but,
as no ID token is issued, it is not echoed anywhere.Register an app
Sign in to the developer site and open the OAuth apps console (New app). Apps belong to your Bodek account.
| Field | Rules |
|---|---|
| Name | Required, up to 80 characters. Shown to users on the sign-in screen. |
| App type | Confidential — server-side apps; receives a client secret. Public — native/mobile/desktop apps and anything that can't keep a secret; no secret, PKCE is mandatory. The type is fixed once the app is created. |
| Redirect URIs | 1–10 URIs, one per line. Absolute URLs of at most 512 characters,
https:// only — http:// is allowed for localhost,
127.0.0.1 and ::1. No #fragment. Matching at
/authorize and /token is exact (scheme, host, port, path
and query string). |
| Scopes | Any of openid, profile, email,
offline_access. openid is always added. The registered set is both the
maximum an authorize request can obtain and the default when a request omits scope. |
| Homepage URL | Optional http(s) URL; its host is shown as a link on the sign-in screen. |
| Logo URL | Optional http(s) URL of an image shown on the sign-in screen. |
On creation you receive a client_id (bk_ + 32 hex characters) and, for
confidential apps, a client_secret (bks_ + 48 hex characters) that is
shown once. Only a hash is stored; if you lose it, rotate it (see
Managing your app).
Discovery & endpoints
The metadata document is served (with Access-Control-Allow-Origin: *) at both
standard locations:
GET https://accounts.bodek.us/.well-known/openid-configuration
GET https://accounts.bodek.us/.well-known/oauth-authorization-server
{
"issuer": "https://accounts.bodek.us",
"authorization_endpoint": "https://accounts.bodek.us/authorize",
"token_endpoint": "https://accounts.bodek.us/token",
"userinfo_endpoint": "https://accounts.bodek.us/userinfo",
"end_session_endpoint": "https://accounts.bodek.us/logout",
"jwks_uri": null,
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"response_types_supported": ["code"],
"response_modes_supported": ["query"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"subject_types_supported": ["public"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"],
"code_challenge_methods_supported": ["S256"],
"claims_supported": ["sub", "name", "given_name", "family_name", "email", "email_verified", "picture", "phone_number"]
}
| Endpoint | Method | URL |
|---|---|---|
| Authorization | GET | https://accounts.bodek.us/authorize |
| Token | POST | https://accounts.bodek.us/token |
| UserInfo | GET / POST | https://accounts.bodek.us/userinfo |
| Introspection | POST | https://accounts.bodek.us/introspect |
| Revocation | POST | https://accounts.bodek.us/revoke |
| End session | GET | https://accounts.bodek.us/logout |
Introspection and revocation are not advertised in the discovery document; configure them explicitly if your library needs them.
Scopes
| Scope | Grants |
|---|---|
openid | Identifies the user (sub). On its own it also releases the profile claims (see UserInfo). |
profile | name, given_name, family_name, picture. |
email | email and email_verified. |
offline_access | A refresh token is returned with the access token. |
Separate scopes with spaces. The granted scope is the requested scope intersected with the scopes
registered for your app; anything else is silently dropped (no error). If you omit
scope, you get every scope registered for the app. The granted value is returned as
scope in the token response — check it rather than assuming.
Token lifetimes
| Artifact | Format | Lifetime | Notes |
|---|---|---|---|
| Authorization code | 64 hex chars | 60 seconds | Single use. Replaying a used code revokes every token issued from it. |
| Access token | 64 hex chars | 3600 seconds | Returned as expires_in. Bearer token for /userinfo. |
| Refresh token | 64 hex chars | 90 days (7,776,000 s) | Rotates on every use; reuse revokes it (see Refresh tokens). |
Authorization code flow (server-side)
For confidential apps with a backend: redirect the user, receive a code on your callback, exchange the code for tokens from your server, then call UserInfo.
1. Redirect the user to /authorize
Generate a random state, store it in the session, and send the user to:
https://accounts.bodek.us/authorize?
response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://your-app.com/oauth/callback
&scope=openid%20profile%20email
&state=RANDOM_STATE
2. Handle the callback
Bodek redirects back with ?code=…&state=… (or ?error=…). Verify that
state matches what you stored, then exchange the code.
3. Exchange the code at /token
curl -X POST https://accounts.bodek.us/token \
-d grant_type=authorization_code \
-d code=THE_CODE \
-d redirect_uri=https://your-app.com/oauth/callback \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET
# or authenticate with HTTP Basic instead of body credentials
curl -X POST https://accounts.bodek.us/token -u YOUR_CLIENT_ID:YOUR_CLIENT_SECRET \
-d grant_type=authorization_code -d code=THE_CODE \
-d redirect_uri=https://your-app.com/oauth/callback
Minimal PHP example
<?php
session_start();
$issuer = 'https://accounts.bodek.us';
$clientId = 'YOUR_CLIENT_ID';
$clientSecret = 'YOUR_CLIENT_SECRET';
$redirectUri = 'https://your-app.com/oauth/callback';
// Step 1 — start.php
$_SESSION['oauth_state'] = bin2hex(random_bytes(16));
$params = http_build_query([
'response_type' => 'code',
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'scope' => 'openid profile email',
'state' => $_SESSION['oauth_state'],
]);
header('Location: ' . $issuer . '/authorize?' . $params);
exit;
// Step 2 + 3 — callback.php
if (isset($_GET['error'])) { exit('Sign-in failed: ' . htmlspecialchars($_GET['error'])); }
if (!hash_equals($_SESSION['oauth_state'] ?? '', $_GET['state'] ?? '')) {
http_response_code(400); exit('Bad state');
}
unset($_SESSION['oauth_state']);
$ch = curl_init($issuer . '/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'authorization_code',
'code' => $_GET['code'] ?? '',
'redirect_uri' => $redirectUri,
'client_id' => $clientId,
'client_secret' => $clientSecret,
]),
]);
$tok = json_decode(curl_exec($ch), true);
if (empty($tok['access_token'])) { exit('Token error: ' . ($tok['error'] ?? 'unknown')); }
// Fetch the verified profile
$ch = curl_init($issuer . '/userinfo');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $tok['access_token']],
]);
$user = json_decode(curl_exec($ch), true);
// $user['sub'], $user['email'], $user['name'] …
Public apps (PKCE)
Apps that can't keep a secret — mobile, desktop and CLI apps — register as public
and use PKCE (RFC 7636). The provider requires a code_challenge for
public clients, and only the S256 method is supported.
- Create a random
code_verifier: 43–128 characters fromA–Z a–z 0–9 - . _ ~. - Derive
code_challenge = BASE64URL(SHA256(code_verifier))(no padding). - Send
code_challengeandcode_challenge_method=S256on the authorize request. - Send the original
code_verifierwith the token request, withclient_idand no client secret.
Confidential apps may also send a challenge; once a code was issued with a challenge, the matching verifier is required to redeem it.
/token, /userinfo,
/introspect and /revoke don't send CORS headers, so JavaScript on another
origin can't read their responses. A single-page app should do the code exchange and UserInfo call
from its own backend (which can then be a confidential client), or run where CORS doesn't apply
(native, desktop, server).// Generate the verifier + challenge (Web Crypto; also works in Node 18+)
const b64url = (bytes) => btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); // 43 chars
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = b64url(new Uint8Array(digest));
const authorizeUrl = 'https://accounts.bodek.us/authorize?' + new URLSearchParams({
response_type: 'code',
client_id: 'YOUR_CLIENT_ID',
redirect_uri: 'http://127.0.0.1:8765/callback',
scope: 'openid profile email offline_access',
state: crypto.randomUUID(),
code_challenge: challenge,
code_challenge_method: 'S256',
});
// open authorizeUrl in the system browser, then receive ?code=… on the redirect URI
curl -X POST https://accounts.bodek.us/token \
-d grant_type=authorization_code \
-d code=THE_CODE \
-d redirect_uri=http://127.0.0.1:8765/callback \
-d client_id=YOUR_CLIENT_ID \
-d code_verifier=THE_VERIFIER
Token endpoint
Body is application/x-www-form-urlencoded. Responses are JSON with
Cache-Control: no-store.
Client authentication
| Method | How |
|---|---|
client_secret_post | client_id and client_secret in the body. |
client_secret_basic | Authorization: Basic base64(client_id:client_secret). Body values take precedence if both are sent. |
none | Public apps: client_id only (plus PKCE). |
grant_type=authorization_code
| Parameter | Description | |
|---|---|---|
code | required | The code from the callback. |
redirect_uri | required | Exactly the value used on /authorize. |
client_id | required | In the body or via Basic auth. |
client_secret | confidential | In the body or via Basic auth. |
code_verifier | if PKCE | Required when the code was issued with a code_challenge. |
grant_type=refresh_token
| Parameter | Description | |
|---|---|---|
refresh_token | required | The most recent refresh token. |
scope | optional | Narrow the scope (a subset of the original grant). Asking only for scopes that weren't granted returns invalid_scope. |
client_id / client_secret | required / confidential | Same client that received the token. |
Response
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "9b0e…c41a",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email offline_access",
"refresh_token": "71d2…0f3e"
}
From the authorization-code grant, refresh_token is present only when
offline_access was granted. The refresh grant always returns a new
refresh_token. There is no id_token.
UserInfo
Returns the user's claims for a valid access token. Send the token as
Authorization: Bearer … (recommended); an access_token query or form
parameter (POST) is also accepted.
curl https://accounts.bodek.us/userinfo \
-H "Authorization: Bearer ACCESS_TOKEN"
{
"sub": "42",
"name": "Ada Lovelace",
"given_name": "Ada",
"family_name": "Lovelace",
"picture": "https://accounts.bodek.us/uploads/profiles/…jpg",
"email": "ada@example.com",
"email_verified": true
}
| Claim | Released with | Notes |
|---|---|---|
sub | always | Stable account id as a string. Use this as your key — it never changes, even if the email does. |
name, given_name, family_name | profile or openid | |
picture | profile or openid | Absolute avatar URL, or null when the user has none. |
email, email_verified | email | email_verified is a boolean. |
Errors: 401 with {"error":"invalid_token"} and a
WWW-Authenticate: Bearer error="invalid_token" header when the token is missing, expired or
revoked; 410 invalid_token if the account no longer exists.
Refresh tokens
Request the offline_access scope (and register it on your app) to receive a refresh
token. Exchange it for a new access token when the old one expires:
curl -X POST https://accounts.bodek.us/token \
-d grant_type=refresh_token \
-d refresh_token=YOUR_REFRESH_TOKEN \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET # omit for public clients
- Rotation: each use returns a new refresh token (valid for a fresh 90 days) and invalidates the old refresh token and the access token issued with it.
- Replay detection: presenting an already-used refresh token is treated as theft —
it fails with
invalid_grant, and (unless it happens within a few seconds of the first use) every access and refresh token your app holds for that user is revoked, so the user must sign in again. Always persist the latest token you receive, and serialize refreshes so two requests don't race with the same token. - A refresh token only works for the client it was issued to.
Introspection
Check whether an access token issued to your app is still active
(RFC 7662 style). Send form fields; client credentials go in the body (HTTP Basic is not accepted
here). Public apps send client_id only.
curl -X POST https://accounts.bodek.us/introspect \
-d token=ACCESS_TOKEN \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET
{
"active": true,
"scope": "openid profile email",
"client_id": "bk_…",
"sub": "42",
"exp": 1750003600,
"iat": 1750000000,
"token_type": "Bearer"
}
Anything else — an expired, revoked or unknown token, a refresh token, or a token belonging to
another app — returns {"active": false}. Bad client credentials return
401 {"error":"invalid_client"}.
Revocation
Invalidate a token immediately — for example when the user signs out of your app (RFC 7009 style). Client credentials go in the body.
curl -X POST https://accounts.bodek.us/revoke \
-d token=THE_TOKEN \
-d token_type_hint=refresh_token \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET
token_type_hintis optional:access_tokenorrefresh_tokenlimits the lookup to that type; without it both are tried.- Revoking a refresh token also revokes the access token issued alongside it.
- The response is always
200with{}— including for unknown or already-revoked tokens — so you can't probe for valid tokens. Only tokens issued to your app are affected. - Errors:
400 {"error":"invalid_request"}withoutclient_id;401 {"error":"invalid_client"}for a bad client or secret.
Signing out
To end the user's session in your app, clear your own session and revoke their tokens.
Bodek's end_session_endpoint (https://accounts.bodek.us/logout) signs the user out of
Bodek itself; when opened from another site it shows a “Sign out?” confirmation page. It does not
support post_logout_redirect_uri — its next parameter only redirects to Bodek
sites — so the user stays on Bodek after signing out.
Managing your app
From the OAuth apps console you can at any time:
- Edit the name, redirect URIs, scopes, homepage and logo. Changes apply to new authorization requests immediately.
- Rotate the secret (confidential apps). A new secret is shown once and the old one stops working at once — deploy the new secret promptly. Existing tokens stay valid.
- Disable the app: sign-ins show “Application disabled”, the token endpoint returns
invalid_client, and all live access and refresh tokens are revoked. Re-enabling doesn't restore them. - Delete the app: revokes and removes all of its codes and tokens and the client itself. This can't be undone.
Two-factor & security
Authentication always happens on Bodek Accounts, never in your app. Before a code is issued, the user completes their entire Bodek login — password plus any enabled second factor (authenticator app, email code or passkey) — and chooses the account to continue with. Your app can't bypass or weaken that step.
- Always send and verify a unique
statevalue to defend against CSRF. - Use PKCE for any client that can't keep a secret (confidential clients can use it too).
- Keep the client secret on your server. If it leaks, rotate it — the old one stops working immediately.
- Redirect URIs are exact-match: register every callback you use, and prefer
https://. - Treat
subas the user id; don't key accounts on email alone. - Disabling or deleting an app revokes its live tokens immediately.
Errors
Authorization errors are returned on your redirect URI as query parameters (error,
error_description, state) — see Authorize endpoint.
Token endpoint errors return JSON {"error": "…", "error_description": "…"}.
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_request | A required parameter is missing (e.g. client_id, code/redirect_uri, refresh_token), or PKCE is missing/invalid on authorize. |
| 401 | invalid_client | Unknown client, wrong or missing secret, or the app is disabled. |
| 429 | slow_down | Too many failed client authentications from your IP (30 per 10 minutes); honour Retry-After. |
| 400 | invalid_grant | Code or refresh token is malformed, unknown, expired, already used, revoked, issued to another client, the redirect_uri doesn't match, or the PKCE verifier is missing or wrong. error_description says which. |
| 400 | invalid_scope | A refresh asked only for scopes that weren't granted. |
| 400 | unsupported_grant_type | grant_type isn't authorization_code or refresh_token. |
| redirect | unsupported_response_type | Only response_type=code is supported. |
| 401 / 410 | invalid_token | UserInfo: missing, invalid, expired or revoked access token (401), or deleted account (410). |
| 405 | invalid_request / method_not_allowed | /token, /introspect and /revoke only accept POST. |