ljsystem/bankid
A Laravel package for integrating Swedish BankID electronic identification and signing into your application. It provides a simple, configurable way to initiate authentication, verify users, and handle BankID flows within PHP projects.
Install and publish config
composer require ljsystem/bankid
php artisan vendor:publish --tag=bankid
This gives you config/bankid.php. The package wraps the BankID RP JSON API (/rp/v6.0), so the first things to set are:
// config/bankid.php
'endpoint' => env('BANKID_ENDPOINT', 'https://appapi2.test.bankid.com/rp/v6.0'),
'certificate' => env('BANKID_CERTIFICATE'), // path to your .p12/.pem
'password' => env('BANKID_PASSWORD'),
'ca_certificate' => env('BANKID_CA_CERTIFICATE'), // required for the test env
Get test credentials before writing any code. Download the BankID test RP certificate from the BankID developer portal, drop the .p12 (and test CA) into storage/app/bankid/, and point the config there. Without a valid cert every request fails with certificateErr — nothing else works until this is done.
First use case — authenticate a user
A minimal controller action that kicks off an auth order:
use Ljsystem\BankID\Facades\BankID;
public function start(Request $request)
{
$result = BankID::auth($request->ip()); // personalNumber optional
return response()->json([
'orderRef' => $result['orderRef'],
'autoStartToken' => $result['autoStartToken'],
]);
}
That single call is the whole "hello world". From there the flow is: client gets autoStartToken → opens bankid:///?autostarttoken=... (mobile) or renders a QR → your app polls collect → user is identified by completionData.user.personalNumber.
The poll loop is the core pattern. auth/sign only create the order; collect drives everything. Put it behind a JSON endpoint and poll from the browser at ~1–2s:
public function collect(Request $request)
{
$result = BankID::collect($request->validate(['orderRef' => 'required|string'])['orderRef']);
return match ($result['status']) {
'complete' => $this->handleComplete($result['completionData']),
'failed' => $this->handleFailure($result['hintCode']),
default => response()->json($result), // pending
};
}
Keep orderRef server-side in the session (or a short-lived DB row) rather than trusting a client-supplied value in production — an attacker who guesses an orderRef can probe someone else's flow.
QR flow. For desktop users, the response from auth also carries qrStartToken and qrStartSecret. You must generate the animated QR yourself (the package just exposes the fields — HMAC is not done for you):
$time = now()->diffInSeconds($startedAt);
$qrAuthCode = hash_hmac('sha256', (string) $time, $qrStartSecret);
$qrData = "bankid.{$qrStartToken}.{$time}.{$qrAuthCode}";
Regenerate every second in JS (or a small /qr endpoint) and encode $qrData as a QR. Cache-bust it — a static QR will fail with expiredTransaction.
Sign flow. Use BankID::sign(...) for contracts / consent rather than plain auth. userVisibleData must be base64-encoded text (BankID enforces an encoding) and shows in the user's app; userNonVisibleData is your own reference (e.g. order ID) — useful for auditing.
$result = BankID::sign(
$request->ip(),
base64_encode("Sign agreement #{$order->id}"),
base64_encode(json_encode(['order_id' => $order->id])),
);
Same-device vs. cross-device. Mobile users tapping bankid:///?autostarttoken=... complete on the same device. Desktop users get the QR path. Decide per-request whether to show a "Open BankID" button or a QR — you can't reliably know from the API, so detect client type in your own code.
Post-completion handling. Treat completionData as the trusted identity: completionData.user.personalNumber, name, givenName, surname. Resolve to a local User via firstOrCreate, log the orderRef and completionData.signature for audit, then issue a session token. Don't put BankID state in the user model — it's an external identity assertion, not a credential.
Cancel on abandonment. Wire a beforeunload / modal-close handler that hits BankID::cancel($orderRef). Orders linger and count against your rate limits otherwise.
endUserIp must be the real client IP. Behind a load balancer, configure Laravel's TrustProxies middleware first or BankID rejects requests. Never pass the server's IP.appapi2.test.bankid.com) is different from production (appapi2.bankid.com). Keep two config blocks and never ship the test cert to prod. If you see certificateErr, it's almost always a wrong cert / missing password / wrong endpoint pairing..p12 with password; others need a PEM chain. If Guzzle throws cURL error 58/60, convert with openssl pkcs12 -in ... -out ... and re-point the config.outstandingTransaction stuck or failed with rate-limit hint codes, back off. Also respect the hint codes: userSign (user needs to confirm), started, userCancel, expiredTransaction.orderRef expires in ~30s of inactivity and is single-use. Never reuse it across flows, and don't store it beyond the auth attempt.php artisan config:cache freezes env() values into the cached config; changing certs afterwards does nothing until you re-run config:cache (or config:clear).bankid:// autostart. On localhost, the mobile autostart link won't fire. Test the real flow with ngrok/valet share or by typing the QR on a phone.completionData is the only trustworthy signal. Don't infer success from a redirect or a poll that returns pending then times out — only status === 'complete' with a valid signature means success.BankIDService and inject the client there. This is also the seam to use for a fake/mocked client in tests — swap the binding in AppServiceProvider::register().hintCode on failures. The API returns terse codes (userCancel, certificateErr, expiredTransaction, startFailed). Logging the raw response plus orderRef and IP will save you hours when a support ticket says "it just didn't work."How can I help you explore Laravel packages today?