BeaconWolf DocsDeveloper Portal

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

Sign in with BeaconWolf

<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_typeShown on the phoneUseful details
paymentAmount, currency, merchantamount, currency, merchant
admin_actionSensitive actionaction (for example "Delete customer #48372")
transaction_approvalApproval neededaction
password_change, document_signing, api_accessType and binding messageaction

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