Hoppa till innehållet
Luxe Group

Guide för utvecklare

Bygga app med BankID – en praktisk guide till integrationen

BankID-inloggning ser enkel ut för användaren men består av ett tiotal detaljer som måste sitta rätt: certifikat, polling, QR-kod, appbyte och felhantering. Här går vi igenom hela flödet som vi själva bygger det, med kod och de fallgropar vi har gått i.

Uppdaterad · Luxe Group AB

Vad du behöver innan du skriver en rad kod

BankID:s API för identifiering och signering heter RP API (Relying Party) och är i dag i version 6. Det är ett JSON-API över HTTPS där din server identifierar sig med ett klientcertifikat (mTLS). Det finns ingen nyckel att lägga i appen – all kommunikation med BankID sker från din backend.

  • Ett BankID-avtal via din bank. Produktionscertifikatet beställs från banken du har avtalet med, inte från BankID direkt.
  • Ett testcertifikat. Testmiljön är fri och öppen; certifikatet laddas ner från BankID:s testsida.
  • En backend. Appen pratar med din server, servern pratar med BankID. Klientcertifikatet får aldrig ligga i appen.
  • Ett test-BankID i BankID-appen, konfigurerad för testmiljön. Det skapar du själv på BankID:s testportal.

Ett alternativ till eget avtal är att gå via en förmedlingstjänst som har avtalet och certifikatet åt dig. Det sänker tröskeln för att komma igång, men du får ett extra led med egna API-format och egen driftstatus. I FolkPanelen började vi via en sådan tjänst; principerna i den här guiden gäller i båda fallen eftersom flödet under huven är detsamma.

Miljöer

  • Test: https://appapi2.test.bankid.com/rp/v6.0/
  • Produktion: https://appapi2.bankid.com/rp/v6.0/

Olika certifikat gäller för test och produktion, och din klient ska lita på BankID:s rot-CA för respektive miljö – inte på själva servercertifikatet, eftersom det byts ut. Se Environments för aktuella rotcertifikat.

Flödet i fyra anrop

  1. Appen ber din backend starta en inloggning. Backend anropar POST /auth och får tillbaka orderRef, autoStartToken, qrStartToken och qrStartSecret.
  2. Backend sparar ordern (inklusive qrStartSecret och tidpunkten för svaret) och skickar orderRef plus antingen autoStartToken eller QR-data till klienten. qrStartSecret lämnar aldrig servern.
  3. Klienten öppnar BankID-appen (samma enhet) eller visar en animerad QR-kod (annan enhet). Under tiden anropar backend POST /collect ungefär varannan sekund.
  4. När /collect svarar complete skapar backend en session. Svarar den failed visar du rätt meddelande utifrån hintCode. Avbryter användaren i din app anropar du POST /cancel.

/sign fungerar likadant som /auth men kräver userVisibleData – texten användaren godkänner. Signering används när användaren faktiskt ska skriva under något, till exempel ett avtal eller ett uttag.

Backend: starta en order

Här är en avskalad version i TypeScript för Node eller Bun. Vi använder undici för att kunna skicka klientcertifikatet; samma sak går att göra med https.request.

import { Agent, request } from "undici";
import { readFileSync } from "node:fs";

const BANKID_URL = process.env.BANKID_URL!; // test- eller produktions-URL
const agent = new Agent({
  connect: {
    pfx: readFileSync(process.env.BANKID_PFX_PATH!),
    passphrase: process.env.BANKID_PFX_PASSPHRASE!,
    ca: readFileSync(process.env.BANKID_CA_PATH!), // BankID:s rot-CA
  },
});

async function rp<T>(path: string, body: unknown): Promise<T> {
  const res = await request(BANKID_URL + path, {
    method: "POST",
    dispatcher: agent,
    // Ingen charset-parameter: BankID svarar 415 om den finns med
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  const json = (await res.body.json()) as any;
  if (res.statusCode !== 200) {
    throw new BankIdError(json?.errorCode ?? "unknown", res.statusCode);
  }
  return json as T;
}

export async function startAuth(opts: { endUserIp: string; nonce: string }) {
  const order = await rp<{
    orderRef: string;
    autoStartToken: string;
    qrStartToken: string;
    qrStartSecret: string;
  }>("/auth", {
    endUserIp: opts.endUserIp,
    returnUrl: "minapp://bankid#nonce=" + opts.nonce,
    userVisibleData: Buffer.from("Logga in i Minapp").toString("base64"),
    app: { appIdentifier: "se.exempel.minapp" },
  });
  await saveOrder({ ...order, startedAt: Date.now() }); // din databas
  return { orderRef: order.orderRef, autoStartToken: order.autoStartToken };
}

Några parametrar förtjänar en kommentar:

  • `endUserIp` ska vara användarens IP som din tjänst ser den – inte adressen till din egen proxy eller lastbalanserare. Läs den från rätt header bakom proxyn, och bara om du litar på proxyn som satte den.
  • `returnUrl` skickas från servern sedan v6, och det är det BankID rekommenderar. Lägg en nonce i den och kontrollera vid återkomsten att den hör till samma session. Att skicka redirect i startlänken är numera markerat som föråldrat.
  • `userVisibleData` är UTF-8-text som base64-kodas. Ge användaren kontext (vilken tjänst, vad som sker) så att den kan upptäcka om någon försöker lura den.
  • `app.appIdentifier` är bundle-id på iOS och paketnamn på Android. BankID skriver uttryckligen att fel värde kan leda till att legitima ordrar blockeras. Skicka app eller web, aldrig båda.

Polla /collect – och hantera hintCodes

/collect returnerar status som är pending, failed eller complete. Vid pending och failed får du en hintCode som styr vad användaren ska se. BankID kan lägga till nya koder utan förvarning, så din kod måste ha en standardgren för okända värden.

type Collect =
  | { status: "pending"; hintCode: string }
  | { status: "failed"; hintCode: string }
  | { status: "complete"; completionData: { user: { personalNumber: string; name: string } } };

const pendingText: Record<string, string> = {
  outstandingTransaction: "Starta BankID-appen.",
  noClient: "Starta BankID-appen.",
  started: "Söker efter BankID, det kan ta en liten stund …",
  userSign: "Skriv in din säkerhetskod eller använd biometri i BankID-appen.",
};

const failedText: Record<string, string> = {
  expiredTransaction: "BankID-appen svarar inte. Försök igen.",
  certificateErr: "Ditt BankID är spärrat eller för gammalt.",
  userCancel: "Åtgärden avbröts.",
  cancelled: "Åtgärden avbröts. Försök igen.",
  startFailed: "BankID-appen verkar inte finnas på enheten.",
};

export async function poll(orderRef: string) {
  const r = await rp<Collect>("/collect", { orderRef });
  if (r.status === "pending") {
    return { state: "pending", message: pendingText[r.hintCode] ?? "Identifiering pågår." };
  }
  if (r.status === "failed") {
    return { state: "failed", message: failedText[r.hintCode] ?? "Okänt fel. Försök igen." };
  }
  return { state: "complete", user: r.completionData.user };
}

Texterna ovan är förkortade. BankID har färdiga, rekommenderade användarmeddelanden (RFA1, RFA9 och så vidare) kopplade till varje kod på sidan User messages. Använd dem – användarna känner igen formuleringarna.

Ett avslutat orderRef går inte att hämta två gånger: ett andra /collect efter complete eller failed ger invalidParameters. Om klienten pollar via din backend och två anrop korsar varandra (dubbelklick, nätverksretry, appen som vaknar ur bakgrund) får du fel trots att inloggningen lyckades. I FolkPanelen löste vi det genom att cacha slutresultatet per orderRef en kort stund och låta samtidiga anrop vänta på samma pågående begäran i stället för att skicka två till BankID.

Fel på HTTP-nivå

  • alreadyInProgress – det finns redan en pågående order för personen. Visa att en inloggning redan pågår.
  • invalidParameters – fel i din begäran. Försök inte igen automatiskt och visa det inte som ett BankID-fel.
  • requestTimeout och internalError – försök inte automatiskt; be användaren försöka igen.
  • maintenance (503) – får försökas igen tyst; upprepas det, informera användaren.

Samma enhet: öppna BankID-appen från React Native

När användaren har BankID på samma telefon som din app startar du BankID-appen med autoStartToken. BankID rekommenderar universallänken https://app.bankid.com/. I en Expo-app räcker Linking:

import * as Linking from "expo-linking";
import { Alert } from "react-native";

export async function openBankId(autoStartToken: string) {
  // returnUrl har redan skickats från servern i /auth, därför ingen redirect här
  const url = "https://app.bankid.com/?autostarttoken=" + autoStartToken;
  try {
    await Linking.openURL(url);
  } catch {
    Alert.alert("BankID", "Kunde inte öppna BankID-appen. Använd BankID på annan enhet.");
  }
}

För att BankID ska kunna skicka tillbaka användaren behöver appen ett registrerat URL-schema eller en universallänk (i Expo: scheme i app.json). Behandla återkomsten som en signal att polla klart, inte som ett resultat. BankID skriver själva att Android inte garanterar att ett resultat kommer tillbaka till din aktivitet – sanningen finns bara i /collect.

Försök inte heller gissa om BankID-appen är installerad. Ge användaren två tydliga val: BankID på den här enheten och BankID på annan enhet. Det är så vi gör i FolkPanelen, och det har gett färre supportärenden än automatiska gissningar.

Annan enhet: animerad QR-kod

Om användaren sitter vid en dator (eller har BankID på en annan telefon) visar du en QR-kod. Statiska QR-koder fungerar inte längre med v6 – koden måste vara animerad och räknas fram på servern varje sekund:

import { createHmac } from "node:crypto";

// startedAt = tidpunkten (ms) då svaret från /auth kom tillbaka
export function qrData(order: { qrStartToken: string; qrStartSecret: string; startedAt: number }) {
  const time = Math.floor((Date.now() - order.startedAt) / 1000).toString();
  const qrAuthCode = createHmac("sha256", order.qrStartSecret).update(time).digest("hex");
  return ["bankid", order.qrStartToken, time, qrAuthCode].join(".");
}

Formatet är bankid.qrStartToken.time.qrAuthCode, där qrAuthCode är HMAC-SHA256 av antalet sekunder med qrStartSecret som nyckel. Låt klienten hämta en ny sträng varje sekund (eller skicka den i samma svar som collect-statusen) och rendera QR-koden lokalt. Generera inte koder i förväg och skicka aldrig qrStartSecret till klienten. Se BankID:s QR-guide.

  • En order är giltig för skanning i 30 sekunder. Vill du visa QR-koden längre skapar du nya ordrar, och ger användaren en knapp för att förlänga.
  • Svart på vitt, låg felkorrigeringsnivå, ingen logga i mitten och aldrig inverterade färger – tänk på det i mörkt läge.
  • Sedan tillgänglighetskraven 2025 ska QR-koden vara en knapp som kan visas i helskärm, och nedräkningen ska fungera med skärmläsare.

Efter complete: vad ska du spara?

completionData innehåller personnummer, namn, enhetens IP och signaturen. Personnumret är en känslig uppgift, och mycket sällan behöver du lagra det i klartext. I FolkPanelen sparar vi en HMAC av personnumret med en serverhemlighet som nyckel. Det räcker för att känna igen en återkommande person (till exempel för att förhindra att samma person tar en välkomstbonus två gånger) utan att databasen innehåller läsbara personnummer. En vanlig SHA-256 utan nyckel räcker inte – personnummer är så få att de går att räkna fram.

  • Jämför gärna completionData.device.ipAddress med IP:t du såg när ordern startades i samma-enhet-flödet.
  • Sätt returnRisk: true om du vill få en riskindikation (low, medium, high) – den förutsätter korrekt endUserIp.
  • Skapa din egen session först när du själv har fått complete från /collect. Lita aldrig på ett klartecken från klienten.

Testmiljön i praktiken

För test behöver du testcertifikatet, test-URL:en och en BankID-app som är konfigurerad för test. Ett test-BankID kan skapas för valfritt personnummer, så använd Skatteverkets testpersonnummer och aldrig riktiga. Vanliga personnummer används av många samtidigt och kan ge konstiga resultat. Spärrat BankID testar du genom att mata in fel säkerhetskod fem gånger.

App Store och Google Play

Granskarna hos Apple har inget svenskt BankID. BankID påpekar själva att App Store-granskningen kräver inloggningsuppgifter till ett demokonto. Bygg därför in en av två saker från början:

  • Ett granskningskonto som loggar in utan BankID, låst till en sandlådad användare utan riktiga pengar eller data.
  • Ett sätt att peka appen mot BankID:s testmiljö, så att granskaren kan använda ett test-BankID.

Google Play frågar efter samma sak under appåtkomst i Play Console. Skriv instruktionerna på engelska och testa själv att de fungerar på en nyinstallerad app. Ett avslag för att granskaren inte kom in kostar ofta flera dagar.

Checklista innan produktion

  • Produktionscertifikat från banken, rätt rot-CA i trust store och ett datum i kalendern för när certifikatet går ut.
  • endUserIp är verklig klient-IP, inte proxyns.
  • returnUrl med nonce skickas från servern och kontrolleras vid återkomsten.
  • Okända hintCode och errorCode har en standardtext.
  • Polling slutar vid failed och complete, och dubbla /collect är hanterade.
  • /cancel anropas när användaren avbryter i din app.
  • Personnummer lagras inte i klartext om det inte krävs.
  • Granskningskonto eller testläge är klart för App Store och Google Play.

Ska BankID kombineras med betalningar? Läs vår guide om Swish-integration, eller se hur vi löser betalningar och BankID i kunduppdrag.

Vanliga frågor

Behöver jag ett eget avtal med BankID?

Avtalet tecknas med din bank, som också utfärdar produktionscertifikatet. Alternativet är att gå via en förmedlingstjänst som har avtalet åt dig. Det går snabbare att komma igång men lägger till ett extra led i flödet.

Kan jag anropa BankID direkt från appen?

Nej. RP API kräver ett klientcertifikat som måste hållas hemligt, så anropen ska alltid gå från din backend. Appen får bara orderRef och autoStartToken, eller färdig QR-data.

Hur ofta ska jag anropa /collect?

BankID anger ungefär varannan sekund så länge statusen är pending. Sluta direkt vid failed eller complete, och anropa inte samma orderRef igen efter att den är avslutad.

Fungerar statiska QR-koder fortfarande?

Nej, stödet för statiska QR-koder har tagits bort i v6. QR-koden ska vara animerad och räknas fram varje sekund med HMAC-SHA256 och qrStartSecret på servern.

Hur får jag igenom en BankID-app i App Store-granskningen?

Ge granskaren ett demokonto som inte kräver BankID, eller ett sätt att köra appen mot BankID:s testmiljö. Beskriv inloggningen tydligt i granskningsanteckningarna och testa instruktionerna själv först.

Kan Luxe Group bygga BankID-inloggningen åt oss?

Ja. Vi har byggt BankID-inloggning och signering i egna produkter som FolkPanelen och gör det även åt kunder, i nya appar eller befintliga. Hör av dig till info@luxegroup.se så tar vi fram en offert.

Läs vidare