Handstamp docs
Connecting an app to Handstamp.
Handstamp is a plain OpenID Connect provider. An app connects to it with a standard OIDC client
configuration, with nothing specific to Handstamp, so the same settings work with Authentik,
Keycloak or Zitadel. This guide uses https://id.rec.farm as the issuer and Paramtune as the
example app.
- Register the app
- App settings
- Rules for app authors
- What the app receives
- Signing out
- Safe return paths
- Worked example: Paramtune with Better Auth
- Endpoints
Register the app
In Handstamp, Admin → Apps → Add an app:
| Field | Example | Notes |
|---|---|---|
| Name | Paramtune |
Shown on the sign-in card. |
| Client ID | paramtune |
Lowercase, stable. It goes into the app's settings. |
| Redirect URIs | https://paramtune.rec.farm/api/auth/oauth2/callback/oidc |
Matched exactly: no wildcards, no prefixes. https:// only, except http://localhost and http://*.localhost. |
| Post-logout redirect URIs | https://paramtune.rec.farm/ |
Where Handstamp may send people after signing out. |
| Scopes it may ask for | openid profile email groups |
Add offline_access only if the app needs refresh tokens. |
| Client type | Server app, secret in the header | client_secret_basic, the default. "Public app" is for SPAs and native apps with no backend; it requires PKCE. |
| Who may sign in | Only members of Paramtune users | Optional. Anyone else is turned away at Handstamp with a clear message and never reaches the app. |
| One of your own apps | on | Skips the "Allow this app?" screen. Leave off for apps run by someone else. |
| Require PKCE | on | Leave on. |
| Back-channel logout URI | https://paramtune.rec.farm/api/auth/backchannel-logout |
Optional. See Signing out. |
The client secret is shown once, together with a ready-to-paste block of settings. Copy it then; Handstamp only stores it encrypted and can't show it again. Lost it? Rotate secret on the app's page issues a new one; the old one stops working at once.
Then create the groups the app cares about (Admin → Groups), for example paramtune-admins,
and add people to them.
Apps can also be declared in a file instead of the admin UI; see Deploying → Clients as code.
App settings
Every R.E.C. app reads the same variables:
| Variable | Example | Meaning |
|---|---|---|
OIDC_ISSUER |
https://id.rec.farm |
The issuer. Discovery is at $OIDC_ISSUER/.well-known/openid-configuration. No trailing slash. |
OIDC_CLIENT_ID |
paramtune |
From the app's page in Handstamp. |
OIDC_CLIENT_SECRET |
hs_sec_… |
Shown once when the app is created or its secret rotated. |
OIDC_SCOPES |
openid profile email groups |
Must be a subset of the scopes the app may ask for. |
OIDC_ROLES_CLAIM |
groups |
The claim that holds group slugs (an array of strings). |
OIDC_ADMIN_GROUP |
paramtune-admins |
Members of this group become admins in the app. Empty: nobody is promoted by Handstamp. |
OIDC_DISPLAY_NAME |
Handstamp |
The button label: "Sign in with Handstamp". |
When OIDC_ISSUER or OIDC_CLIENT_ID is empty, the app should hide the button and keep working
with whatever other sign-in it has.
Rules for app authors
- Key users on
(iss, sub).subis stable and never reused. Store the pair and look users up by it. Don't link or merge accounts by email: an email can change hands, and another provider may claim one it never checked. Turn off your auth library's automatic account linking. - Use the authorization code flow with PKCE (S256), and verify the ID token: signature
against the JWKS,
iss,aud,expandnonce. Handstamp only offersresponse_type=code. - Read roles at every sign-in from
OIDC_ROLES_CLAIM, and overwrite what you stored. A group change in Handstamp then takes effect the next time the person signs in. - Let Handstamp decide who gets in. Use "Who may sign in" rather than checking groups in the app to refuse people. They get a clear message at Handstamp instead of a broken session in your app.
- Treat
emailas contact data, not identity.email_verifiedistrueonly when Handstamp knows the address is real (it came from GitHub's or Google's verified emails, or a trusted issuer). - Fetch the JWKS by
kidand refresh it on an unknownkid. Keys rotate every 90 days and are published a day before they're used, so any library that caches JWKS this way works. - Sign people out properly, below, and register a back-channel logout URI.
- Only redirect to your own paths after sign-in. See Safe return paths.
What the app receives
| Claim | Where | Scope | Meaning |
|---|---|---|---|
sub |
ID token, userinfo | openid |
Stable, opaque user id. The identity, together with iss. |
acr |
ID token | openid |
phr for a passkey sign-in (phishing-resistant), 0 otherwise. See Asking for a passkey. |
amr |
ID token | openid |
["pop","user","mfa"] when the person signed in with a passkey. Absent for GitHub/Google/OIDC sign-ins. |
sid |
ID token | openid |
The Handstamp session, to match back-channel logouts. Sent to apps with a back-channel logout URI. |
auth_time |
ID token | openid |
When the person last authenticated. Always sent. |
groups |
ID token, userinfo | groups |
Group slugs, e.g. ["paramtune-admins"]. Always an array. |
name, preferred_username, updated_at |
userinfo | profile |
Display name and handle. |
email, email_verified |
userinfo | email |
See rule 5. |
Profile and email claims are in userinfo, not the ID token, as the OIDC spec intends for the
code flow. Most libraries call userinfo after the token exchange; make sure yours does (Better
Auth's genericOAuth does).
Asking for a passkey
An app that needs a phishing-resistant sign-in (say, before showing billing or API keys) sends
acr_values=phr with the authorization request. Discovery lists acr_values_supported: ["phr","0"].
- If the person's Handstamp session came from a passkey, they go straight through, as usual.
- If it came from GitHub, Google or another OIDC provider, Handstamp asks for their passkey and offers nothing else. It refuses any other method on the server too, not only on the card.
- With
prompt=none, a session below the level getslogin_requiredinstead. acr_values=phr 0means "a passkey if there already is one, anything otherwise". Values Handstamp doesn't know are ignored.
id_token.acr in the claims parameter (value or values) works the same way. Check the
acr claim in the ID token either way: a request is only a request, and the token is what was
done. Add max_age=0 to make someone sign in again even when they already have a passkey session.
Refresh tokens: an app allowed offline_access gets one with every sign-in. It rotates on every
use (reusing an old one revokes the whole grant) and ends when the person signs out of Handstamp,
is turned off, or the app is.
Signing out
From the app
Clear your own session, then send the browser to Handstamp's end-session endpoint so the Handstamp session ends too:
https://id.rec.farm/session/end?client_id=paramtune
&id_token_hint=<the ID token>
&post_logout_redirect_uri=https://paramtune.rec.farm/post_logout_redirect_uri must be one of the app's registered post-logout URIs, matched exactly.
Handstamp asks the person to confirm, with "Also sign out of Handstamp" ticked by default. Ticked,
every app in that Handstamp session is signed out; unticked, only yours is. Either way your own
back-channel logout URI is called too, so make that handler idempotent.
From Handstamp: back-channel logout
An app with a back-channel logout URI hears about sign-outs that happen anywhere else, server to server (OpenID Back-Channel Logout 1.0). Without one, the app's session simply lives on until it expires.
Register the URI in either place:
Admin UI: Admin → Apps → the app → Back-channel logout URI.
Clients file (
HANDSTAMP_CLIENTS_FILE): add"backchannelLogoutUri"to the app's entry, then redeploy (the file is read at startup):{ "clientId": "paramtune", "name": "Paramtune", "redirectUris": ["https://paramtune.rec.farm/api/auth/oauth2/callback/oidc"], "postLogoutRedirectUris": ["https://paramtune.rec.farm/"], "backchannelLogoutUri": "https://paramtune.rec.farm/api/auth/backchannel-logout", "secretEnv": "PARAMTUNE_OIDC_CLIENT_SECRET" }
The R.E.C. apps serve it at these paths, after the origin set in each app's own config:
| App | Back-channel logout URI |
|---|---|
| Paramtune | <PUBLIC_ORIGIN>/api/auth/backchannel-logout |
| StillUp | <APP_ORIGIN>/api/v1/auth/oidc/backchannel-logout |
| Valtr | In progress; see its own docs. |
The URI follows the redirect URI rules: absolute, https:// (plain http:// only for
localhost and *.localhost), no wildcard, no #fragment, no credentials. Once it's set, ID
tokens for the app carry sid; discovery advertises backchannel_logout_supported and
backchannel_logout_session_supported.
When Handstamp calls it. Once for each Handstamp session that included your app:
| What happened | Apps that are told |
|---|---|
| The person signs out at Handstamp with "Also sign out of Handstamp" (from any app or the account page) | Every app in that session |
| The person signs out of one app and unticks "Also sign out of Handstamp" | That app only |
| Account page: Sign out on one device | Every app in that session |
| Account page: Sign out everywhere | Every app in every session |
| Admin → Users → the person: Sign out everywhere, or Turn off account | Every app in every session |
| A recovery link that removes the person's other sign-in methods is used | Every app in every session |
| Someone else signs in on the same browser | Every app in the previous session |
Sessions that just expire (idle or maximum age) don't trigger a call, and neither does turning an
app off. Handstamp also revokes the grants behind those sessions, so the refresh tokens stop
working: on a sign-out at Handstamp (either way) and when someone else signs in, all but
offline_access ones, which outlive the session; from the account page, by an admin or by
recovery, all of them.
The request.
POST /api/auth/backchannel-logout HTTP/1.1
Host: paramtune.rec.farm
User-Agent: Handstamp
Content-Type: application/x-www-form-urlencoded
logout_token=eyJhbGciOiJSUzI1NiIsInR5cCI6ImxvZ291dCtqd3QiLCJraWQiOiLigKYifQ.…The logout_token is a JWT signed with the same keys as ID tokens. Header:
{"alg":"RS256","typ":"logout+jwt","kid":"…"}. Claims:
| Claim | Value |
|---|---|
iss |
The issuer, e.g. https://id.rec.farm. |
aud |
Your client ID, as a string. |
iat |
When it was issued. |
exp |
iat + 120 seconds. |
jti |
A random ID, unique per token. |
sub |
The person: the same sub as in your ID tokens. |
sid |
The session, the same sid as in the ID token you got for it. It's per app: don't compare it across apps. |
events |
{"http://schemas.openid.net/event/backchannel-logout": {}} |
There is never a nonce. Both sub and sid are always present.
Handling it.
- Verify the token as the spec says (§2.6): signature against the JWKS by
kid(refetch it on an unknownkid),typislogout+jwt,issis your issuer,audis your client ID,iat/expwithin a small clock skew,eventshas the back-channel logout member, and there's nononce. Optionally rememberjtiuntilexpand reject repeats. Most OIDC libraries have a helper for this; don't reuse your ID-token check unchanged, because it would demand anonce. - End your sessions for
(iss, sub, sid). If you don't storesid, end every session for(iss, sub). Revoke any Handstamp refresh token you hold for them. - Answer
200withCache-Control: no-store, or400if the token is invalid. The body is ignored. Answer quickly: Handstamp waits 10 seconds.
The endpoint takes no cookie and no CSRF token: exempt it from your CSRF middleware and your sign-in requirement. The signed token is the authentication.
Reachability. The call comes from the Handstamp server, not the person's browser, so the URI must resolve and be reachable from wherever Handstamp runs. Handstamp:
- refuses loopback, private, link-local and other non-public addresses unless
ALLOW_PRIVATE_NETWORKSis on, and connects to exactly the address it checked (no DNS rebinding); - doesn't follow redirects: a
301or302counts as a failure, so register the final URL; - tries once. A failure (no answer, a timeout, a non-2xx status) is logged on Handstamp as
back-channel logout failedwith your client ID, and not retried. Keep your own sessions reasonably short so a missed call can't keep someone signed in forever.
Safe return paths
Apps usually carry a "where to go after signing in" value through the flow (returnTo,
callbackURL, next). That's an app-side parameter, not part of OIDC, and an open redirect if
it's trusted: a link to your sign-in with returnTo=//evil.example lands someone on a look-alike
site right after a real sign-in. Store it with the flow state (not in the redirect_uri), and
accept only a path on your own origin. A prefix check like "starts with /" isn't enough. Reject,
and fall back to a fixed page, when the value:
- doesn't start with exactly one
/://evil.exampleandhttps://evil.exampleleave your origin (a network-path reference); - contains a backslash: browsers treat
\as/, so/\evil.exampleis//evil.example; - contains control characters (
\t,\r,\n,\x00–\x1f,\x7f): browsers drop tabs and newlines while parsing, so/\t/evil.examplebecomes//evil.example, and\r\ncan split headers; - has a
.or..dot segment, also percent-encoded (%2e,%2E): normalization turns/.//evil.exampleinto//evil.example, and/./api/sign-outinto an API route.
The robust form: parse it against your own origin (new URL(value, origin)) after the checks
above, require the same origin, and require the normalized path not to start with //. Also keep
your own API and auth routes (/api/…) off the list of allowed targets. Paramtune's
safe_return (backend/src/auth.rs)
and safeReturnTo (src/lib/returnTo.ts)
do all of this, with a test for each case.
Worked example: Paramtune with Better Auth
Better Auth 1.7, genericOAuth plugin. The provider ID is the generic oidc, not handstamp,
so moving to another IdP only means changing env vars.
// src/lib/auth.ts
import { betterAuth } from 'better-auth';
import { genericOAuth } from 'better-auth/plugins';
const oidcEnabled = Boolean(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID);
const rolesClaim = process.env.OIDC_ROLES_CLAIM ?? 'groups';
const adminGroup = process.env.OIDC_ADMIN_GROUP;
export const auth = betterAuth({
// …existing database, baseURL, secret…
user: { additionalFields: { role: { type: 'string', defaultValue: 'member', input: false } } },
account: { accountLinking: { enabled: false } }, // rule 1: never link by email
plugins: [
...(oidcEnabled
? [
genericOAuth({
config: [
{
providerId: 'oidc',
discoveryUrl: `${process.env.OIDC_ISSUER}/.well-known/openid-configuration`,
clientId: process.env.OIDC_CLIENT_ID!,
clientSecret: process.env.OIDC_CLIENT_SECRET,
scopes: (process.env.OIDC_SCOPES ?? 'openid profile email').split(/\s+/),
// Better Auth sends the secret in the body by default; Handstamp apps default to
// the header. Either pick 'basic' here, or "secret in the body" in Handstamp.
authentication: 'basic',
pkce: true,
requireIdTokenVerification: true,
overrideUserInfo: true, // rule 3: refresh name, email and role at every sign-in
mapProfileToUser: (profile) => {
const groups = Array.isArray(profile[rolesClaim]) ? profile[rolesClaim] : [];
return {
name: profile.name ?? profile.preferred_username,
email: profile.email,
role: adminGroup && groups.includes(adminGroup) ? 'admin' : 'member',
};
},
},
],
}),
]
: []),
],
});The button: authClient.signIn.oauth2({ providerId: 'oidc', callbackURL: '/' }), labelled
Sign in with ${OIDC_DISPLAY_NAME}. The callback URL to register in Handstamp is
<app origin>/api/auth/oauth2/callback/oidc.
Paramtune's .env:
OIDC_ISSUER=https://id.rec.farm
OIDC_CLIENT_ID=paramtune
OIDC_CLIENT_SECRET=hs_sec_…
OIDC_SCOPES=openid profile email groups
OIDC_ROLES_CLAIM=groups
OIDC_ADMIN_GROUP=paramtune-admins
OIDC_DISPLAY_NAME=HandstampBetter Auth keys accounts on (providerId, accountId) with accountId = sub. That is rule 1 as
long as one providerId maps to one issuer: if you ever point oidc at a different issuer, the
old accounts won't match, which is the safe failure.
Endpoints
All of them are in the discovery document; apps should read them from there.
| Endpoint | Path |
|---|---|
| Discovery | /.well-known/openid-configuration |
| Authorization | /auth |
| Token | /token |
| Userinfo | /me |
| JWKS | /jwks |
| End session | /session/end |
| Revocation | /token/revocation |
| Introspection | /token/introspection |
Try it end to end locally with the example app in examples/test-app
(pnpm example, see the README).