Add "Sign in with BeaconWolf" in 15 minutes
Standard OpenID Connect. Any OIDC library works. Your users approve on their phone; you receive a signed ID token. You never store a password.
Issuer: https://connect.beaconwolf.com
Discovery: https://connect.beaconwolf.com/.well-known/openid-configuration
1Create an application
Open the Developer Portal, sign in with the BeaconWolf app and create an application with your domain and redirect URL (for example https://myshop.com/auth/callback). Store the client secret on your server. It is shown once.
2Add the button
<a href="/auth/login" style="display:inline-flex;align-items:center;gap:10px;background:#050E1B;color:#EAF1FA; border:1px solid #22406B;border-radius:10px;padding:10px 16px;font:600 15px system-ui,sans-serif;text-decoration:none"> <span style="width:10px;height:10px;border-radius:50%;background:#E8801E"></span>Sign in with BeaconWolf </a>
3Send the user to BeaconWolf
Your /auth/login route creates state, nonce and a PKCE verifier, keeps them in the user's session and redirects:
import crypto from "node:crypto";
const ISSUER = "https://connect.beaconwolf.com";
app.get("/auth/login", (req, res) => {
const verifier = crypto.randomBytes(32).toString("base64url");
req.session.oidc = { state: crypto.randomUUID(), nonce: crypto.randomUUID(), verifier };
const u = new URL(ISSUER + "/authorize");
u.search = new URLSearchParams({
response_type: "code",
client_id: process.env.BW_CLIENT_ID,
redirect_uri: "https://myshop.com/auth/callback",
scope: "openid login",
state: req.session.oidc.state,
nonce: req.session.oidc.nonce,
code_challenge: crypto.createHash("sha256").update(verifier).digest("base64url"),
code_challenge_method: "S256",
});
res.redirect(u.toString());
});
4Handle the callback
app.get("/auth/callback", async (req, res) => {
const saved = req.session.oidc;
if (!saved || req.query.state !== saved.state) return res.status(400).send("Invalid state");
if (req.query.error) return res.redirect("/login?cancelled=1");
const r = await fetch(ISSUER + "/oidc/token", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: "Basic " + Buffer.from(process.env.BW_CLIENT_ID + ":" + process.env.BW_CLIENT_SECRET).toString("base64"),
},
body: new URLSearchParams({ grant_type: "authorization_code", code: req.query.code,
redirect_uri: "https://myshop.com/auth/callback", code_verifier: saved.verifier }),
});
const { id_token } = await r.json();
const claims = await verifyIdToken(id_token, saved.nonce); // step 5
req.session.user = { id: claims.sub, username: claims.preferred_username, name: claims.name, email: claims.email };
res.redirect("/account");
});
5Verify the ID token
Check the ES256 signature against https://connect.beaconwolf.com/oidc/jwks.json, and iss, aud (your client ID), exp and nonce. With the jose package:
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL(ISSUER + "/oidc/jwks.json"));
async function verifyIdToken(token, nonce) {
const { payload } = await jwtVerify(token, JWKS, { issuer: ISSUER, audience: process.env.BW_CLIENT_ID });
if (payload.nonce !== nonce) throw new Error("nonce mismatch");
return payload;
}
Use sub as the stable user ID. That's it: your users now sign in without a password.
+Authorization API: approve payments and sensitive actions
Enable Authorization API on your application. Your server then asks a signed-in user to approve a specific action on their phone. The phone shows your application name, your domain and the details you send.
// 1. Ask for approval (server to server)
const r = await fetch(ISSUER + "/oidc/bc-authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded", authorization: basicAuth },
body: new URLSearchParams({
scope: "openid approve",
login_hint: user.username,
request_type: "payment",
binding_message: "Order 1042",
details: JSON.stringify({ amount: "850.00", currency: "EUR", merchant: "My Shop" }),
}),
});
const { auth_req_id, interval } = await r.json();
// 2. Poll every `interval` seconds until approved, denied or expired
const t = await fetch(ISSUER + "/oidc/token", { method: "POST", headers: { ...sameHeaders },
body: new URLSearchParams({ grant_type: "urn:openid:params:grant-type:ciba", auth_req_id }) });
// 400 authorization_pending | access_denied | expired_token, or 200 with a signed id_token
| request_type | Shown on the phone | Useful details |
|---|---|---|
payment | Amount, currency, merchant | amount, currency, merchant |
admin_action | Sensitive action | action (for example "Delete customer #48372") |
transaction_approval | Approval needed | action |
password_change, document_signing, api_access | Type and binding message | action |
The signed id_token contains pap_request_type and auth_req_id, so you can store proof of who approved what, and when.
Good to know
- A request is valid for 60 seconds and can be approved once.
- Codes are single use and valid for 60 seconds. PKCE is recommended for every app.
- Your client secret belongs on your server only. Rotate it in the portal if it leaks.
- For local testing,
http://localhostredirect URLs are allowed.