Den 21. februar 2025 stjal Nordkoreas statslige hackere $1,5 mia. fra kryptobørsen Bybit på ét enkelt angreb. Det er det største kryptotyveri i historiens løb, og det ændrede på fundamentalt vis, hvordan sikkerhedseksperter tænker om supply chain-sikkerhed i finanssektoren.

Det største kryptotyveri i historien

Bybit er en af verdens største kryptobørser med base i Dubai og millioner af brugere globalt. Den 21. februar 2025 opdagede CEO Ben Zhou, at en Ethereum-koldlomme var tømt. Over 400.000 ETH, svarende til cirka $1,5 mia., var overført til ukendte adresser kontrolleret af hackere.

Angrebet oversteg i størrelse alle tidligere kryptobrud. Det amerikanske forbundspoliti FBI bekræftede den 26. februar 2025, at angrebet stod Nordkorea bag, og navngav specifikt den statslige hackergruppe TraderTraitor, der er en undergren af det berygtede Lazarus Group. Ifølge FBI-rapporten var midlerne allerede spredt på tværs af tusindvis af blockchain-adresser på flere netværk inden for dage efter tyveriet.

Kryptoanalysefirmaet Chainalysis, der samarbejdede med Bybit og myndigheder om at spore midlerne, fastslog efterfølgende, at 2025 var et rekordår for kryptotyveri med et samlet tab på over $3,4 mia. Bybit-angrebet alene stod for $1,5 mia. af dette beløb, og Nordkorea var ansvarlig for $2,02 mia. ud af det samlede tyveri, en stigning på 51 procent sammenlignet med 2024.

Begivenheden sendte chokbølger gennem kryptoverdenen og satte spørgsmålstegn ved, om selv de mest sikkerhedsbevidste børser kan beskytte sig mod sofistikerede statslige angribere.

Sådan fungerede angrebet: Safe{Wallet} som indgangspunkt

Angrebsmetoden var ikke en traditionel direkte hacking af Bybit selv, men derimod et supply chain-angreb rettet mod en tredjepartsudbyder. Ben Zhou forklarede kort efter angrebet, at bruddet stammede fra infrastruktur knyttet til multisig-platformen Safe{Wallet}, som Bybit brugte til at autorisere store transaktioner fra koldlommer.

Safe Ecosystem Foundation bekræftede efterfølgende, at hackerne kompromitterede en udviklermaskine hos Safe{Wallet}. Via denne adgang injicerede angriberne ondsindet JavaScript-kode i transaktionsigneringsprocessen. Koden var konstrueret til at se legitim ud, mens den i baggrunden manipulerede transaktioner, der opfyldte bestemte kriterier.

Da Bybit-medarbejdere den 21. februar godkendte en planlagt overførsel fra koldlomme til varm lomme, troede de, at de godkendte en normal intern transaktion. I virkeligheden signerede de en transaktion, der sendte 401.347 ETH direkte til hackernes egne wallets.

Sikkerhedsforskerne hos Trail of Bits beskrev angrebet som udtryk for en ny epoke med operationelle sikkerhedsfejl: “Angreb som Bybit demonstrerer, at selv robust wallet-sikkerhed er utilstrækkelig, hvis signeringsinfrastrukturen er kompromitteret. Organisationer, der opbevarer betydelige kryptoaktiver, skal implementere isolerede, air-gapped signeringsinfrastrukturer og regelmæssigt teste deres incident response-planer,” lød det fra sikkerhedsteamet i en offentlig analyse.

Angrebet illustrerer et centralt problem i hele kryptosektoren: tredjepartsafhængigheder skaber svage led, som angribere kan udnytte. NIS2-direktivet i Danmark adresserer præcis dette problem ved at stille krav om supply chain-sikkerhedsvurderinger for kritiske virksomheder, og Bybit-sagen understreger, hvorfor sådanne krav er nødvendige.

Hvad blev stjålet? De præcise tal

Bybits officielle hændelsesrapport dokumenterede præcis, hvad hackerne kom afsted med. Tyveriet ramte en enkelt Ethereum-koldlomme og bestod af fire separate tokentyper.

TokenAntalVærdi i USD (februar 2025)
ETH (Ethereum)401.347$1,12 mia.
stETH (staked Ethereum)90.375$253 mio.
cmETH15.000$44 mio.
mETH8.000$23 mio.
Total514.722 tokens$1,46 mia.

Den samlede USD-værdi varierede mellem $1,46 mia. og $1,5 mia. afhængig af valueringstidspunkt og kilde, da ETH-kursen bevægede sig i de timer efter angrebet. Begge tal er dokumenteret af henholdsvis Bybits egne rapporter og FBI’s offentlige meddelelse.

Lazarus Group og TraderTraitor: Nordkoreas digitale bankrøvere

Lazarus Group er Nordkoreas mest produktive statssponsorerede hackergruppe og har siden 2017 stjålet kryptovaluta for milliarder af dollars. TraderTraitor er en undergruppe, der fokuserer specifikt på kryptoindustrien og har opereret siden mindst 2020.

FBI, Japans nationale politiagentur og Wiz Research har alle offentligt attribueret TraderTraitor som en del af Lazarus-paraplyen. Gruppen kombinerer sofistikeret social engineering med teknisk manipulation af web-applikationer og supply chain-angreb for at nå sine mål.

Wiz Research beskriver TraderTraitors metodik: gruppen “blander nationalstatssofistikation med kriminelle taktikker, der er stærkt afhængige af social engineering, trojanske applikationer og supply chain-kompromitteringer for at stjæle digitale aktiver.” Det præcis dette mønster, der kendetegnede Bybit-angrebet, ikke et direkte angreb på Bybit, men via tredjeparten Safe{Wallet}.

Nordkorea bruger de stjålne kryptomidler til at finansiere statens økonomi og ballistiske missilprogram, ifølge amerikanske efterretningstjenester. Chainalysis dokumenterede i sin 2026-rapport om kryptotyveri, at Nordkorea siden 2017 har stjålet kryptovaluta svarende til $6,75 mia. i alt, en sum der overstiger mange landes samlede BNP.

Nordkoreas største kryptokup fra de seneste år illustrerer omfanget:

  • Ronin Network-angrebet (2022): $625 mio.
  • Harmony Horizon Bridge-angrebet (2022): $100 mio.
  • DMM Bitcoin (2024): $305 mio.
  • WazirX (2024): $235 mio.
  • Bybit (2025): $1,5 mia.

CrowdStrike’s 2026 Global Threat Report dokumenterede, at den gennemsnitlige eCrime-breaktid faldt til kun 29 minutter i 2025, en stigning på 65 procent i hastighed sammenlignet med 2024. Statssponsorerede angribere som Lazarus Group opererer med endnu større præcision og tålmodighed end gennemsnitlige cyberkriminelle.

De første 48 timer: Rekordhurtig hvidvaskning

Det, der kendetegnede Bybit-angrebet fremfor alt andet, var hvidvaskningens hidtil usete hastighed. TRM Labs, der sporede transaktionerne i realtid, dokumenterede et bemærkelsesværdigt forløb i de første dage.

Dato og tidspunktHændelseBeløb
21. feb. 2025, morgenAngrebet gennemføres via kompromitteret Safe{Wallet}$1,46 mia. stjålet
21. feb. 2025, aftenElliptic og Chainalysis attribuerer til Lazarus GroupMidler spredt på 50 wallets
22. feb. 2025Bybit lancerer 10% bounty-programOp til $150 mio. i præmie
23. feb. 2025, 15:41 UTC$42,89 mio. indefrosset af industripartneremETH Protocol indfrier 15.000 cmETH
23. feb. 2025Over $200 mio. allerede hvidvasket (TRM Labs)Spredning accelererer
24. feb. 2025, 02:35 UTCBybit modtager $1,23 mia. via nødlån og OTCETH-gap lukket
26. feb. 2025FBI offentliggør PSA og 51 Ethereum-adresser$400 mio.+ hvidvasket
26. feb. 2025Bybit lukker ETH-gap fuldt ud inden for 72 timer100%+ kollateralisering bekræftet

Midlerne blev i første omgang spredt til 50 separate wallets med cirka 10.000 ETH i hver. Herfra brugte angriberne en kombination af intermediære wallets, konverteringer til andre kryptovalutaer, decentraliserede børser og cross-chain bridges til at sløre sporet.

TRM Labs vurderede, at ved den 26. februar, blot fem dage efter angrebet, var over $400 mio. allerede bevæget på en måde, der gjorde dem meget svære at genvinde. Hvidvaskningens operationelle effektivitet var hidtil uset og vidner om den ressourcemæssige og tekniske kapacitet hos en statssponsoreret aktør.

Bybits kriserespons: $1,23 mia. på 72 timer

Trods den enorme størrelse af tyveriet lykkedes det Bybit at forhindre en brugerpanik og bevare solvensen. CEO Ben Zhou meddelte via X: “Bybit er solvent. Selv om dette hack opstod, er alle klientaktiver dækkede 1:1, og vi kan dække tabet.” Udmeldingen var afgørende for at bremse potentiel panik på platformen.

Bybit igangsatte øjeblikkeligt et massivt nødfinansieringsprogram. Inden for 72 timer modtog børsen 447.000 ETH via en kombination af nødlån, store whale-indskud og OTC-køb fra partnere, herunder Galaxy Digital, FalconX, Wintermute, Bitget, MEXC, DWF Labs og DigitalX.

Den uafhængige sikkerhedsrevisionsvirksomhed Hacken verificerede efterfølgende, at Bybit havde genoprettet sine reserver fuldt ud, og at alle primære aktiver, herunder BTC, ETH, SOL, USDT og USDC, oversteg 100 procent kollateraliseringsratio. Med andre ord var alle brugerindskud fuldt dækkede.

Bybit lancerede parallelt et recovery bounty-program, der tilbød en præmie på 10 procent af eventuelt generhvervede midler. Med $1,5 mia. som udgangspunkt svarer 10 procent teoretisk til op til $150 mio. i præmier til dem, der hjælper med at fryse eller genvinde de stjålne tokens.

Responsen demonstrerede, at selv et rekordstort angreb ikke nødvendigvis behøver at true en velkapitaliseret børs med direkte brugerkonsekvenser, hvis man handler hurtigt og transparent. EU’s DORA-forordning for finanssektoren stiller præcis sådanne krav om robusthed og hurtig incident response-kapacitet.

Hvem stoppede en del af pengene?

Et koordineret industriindsats forsøgte hurtigt at indefryse en del af midlerne, inden de blev hvidvasket. Chainalysis meddelte, at firmaet arbejdede tæt med kontakter i kryptobranchen og formåede at fryse over $40 mio. i stjålne midler i de første dage.

Bybit oplyste den 23. februar, at i alt $42,89 mio. var indefrosset takket være koordinerede indsatser fra en bred vifte af industripartnere:

  • Tether: frøs USDT-relaterede wallets
  • THORChain: blokerede cross-chain-transaktioner
  • ChangeNOW: stoppede mistænkelige konverteringer
  • FixedFloat: blokerede relaterede transaktioner
  • Avalanche: frøs midler på netværket
  • CoinEx: suspenderede mistænkelige konti
  • Bitget: blokerede relaterede transaktioner
  • Circle: frøs USDC-midler associeret med angrebet

Selvom $42,89 mio. lyder som et stort beløb isoleret set, udgjorde det blot 2,9 procent af de samlede stjålne midler. Det fremhæver en grundlæggende udfordring i kryptosektoren: når midler spredes hurtigt på tværs af tusindvis af adresser og konverteres via DEX’er og bridges, er det ekstremt vanskeligt at indefryse mere end en brøkdel.

FBI offentliggjorde en liste over 51 specifikke Ethereum-adresser knyttet til hvidvaskningsoperationen og opfordrede alle kryptobørser og infrastrukturudbydere til at blokere transaktioner associeret med disse adresser. Det var en usædvanlig direkte intervention fra FBI i et kryptoangreb. Du kan læse FBI’s fulde offentlige meddelelse om Bybit-angrebet på IC3.gov.

FBI’s offentlige advarsel: TraderTraitor navngives

Den 26. februar 2025 udgav FBI et Public Service Announcement via Internet Crime Complaint Center (IC3), der officielt attribuerede angrebet til Nordkorea under kampagnenavnet “TraderTraitor.” Det var en sjælden offentlig attribution af et kryptostyveri til en specifik nationalstat.

“TraderTraitor-aktørerne handler hurtigt og har konverteret noget af det stjålne til Bitcoin og andre virtuelle aktiver, der er spredt på tværs af tusindvis af adresser på flere blockchains. Det forventes, at disse aktiver vil blive yderligere hvidvasket og i sidste ende konverteret til fiat-valuta,” lød det fra FBI i det officielle varsel.

FBI opfordrede specifikt kryptobørser, DeFi-protokoller, blockchain-analysefirmaer og andre aktører i kryptoindustrien til at blokere alle transaktioner relateret til de offentliggjorte adresser og være opmærksomme på forsøg på at konvertere midler til fiat.

Denne type offentlig attribution og koordineret industrirespons repræsenterer en modning i, hvordan myndighederne reagerer på statssponsorerede kryptotyveri. Picus Securitys dybdegående analyse af FBI’s attribuering giver teknisk baggrund for, hvordan efterforskerne koblede angrebet til Lazarus Group via blockchain-forensics og operationelle mønstre.

Nordkoreas kryptokrig: $6,75 mia. i alt

For at forstå Bybit-angrebet i et bredere perspektiv er det vigtigt at se det som en del af Nordkoreas systematiske kryptokriminalitetskampagne, der strækker sig over næsten et årti.

Chainalysis dokumenterede i sin 2026-rapport om kryptohacking, at Nordkorea i 2025 alene stjal kryptovaluta for $2,02 mia., en stigning på 51 procent sammenlignet med 2024, hvor tallet var $1,34 mia. Det bringer Nordkoreas kumulerede kryptotyveri siden 2017 op på en samlet sum af $6,75 mia.

KryptostyveriÅrMålBeløb (USD)
Bybit2025Ethereum koldlomme$1,50 mia.
DMM Bitcoin2024Japansk kryptobørs$305 mio.
WazirX2024Indisk kryptobørs$235 mio.
Ronin Network2022Axie Infinity-bridge$625 mio.
Harmony Horizon Bridge2022Cross-chain bridge$100 mio.
Kucoin2020Kryptobørs$281 mio.

Pengene finansierer direkte Nordkoreas ballistiske missilprogram, ifølge amerikanske og FN-relaterede efterretningsanalyser. Det gør Lazarus Groups kryptoangreb til en geopolitisk sikkerhedstrussel med konsekvenser, der rækker langt ud over kryptobranchen selv.

Den bredere kontekst er vigtig for danske virksomheder og institutioner at forstå. Cybertrusler mod Norden er ikke begrænset til ransomware-grupper eller opportunistiske hackere. Statslige aktører som Lazarus Group opererer med en præcision og vedholdenhed, der kræver en fundamentalt anderledes sikkerhedsmentalitet. Chainalysis’ 2026-rapport om kryptotyveri dokumenterer dette mønster i detaljer.

Markedskonsekvenser: Ethereum faldt 5 procent

Angrebet sendte umiddelbare chokbølger gennem kryptomarkederne. ETH-prisen faldt med ca. 5 procent i dagene umiddelbart efter angrebet. Bitcoin og andre større kryptovalutaer oplevede ligeledes kortvarige fald som reaktion på nyheden om rekordbristen.

Markedet stabiliserede sig relativt hurtigt, delvist takket være Bybits hurtige og transparente kommunikation om, at børsen forblev solvent og alle brugerindskud var sikre. Den hurtige genoprettelse af reserver sendte et signal til markedet om, at systemisk risiko var begrænset til ét brud hos én aktør.

Bybit-angrebet rejste vigtige spørgsmål om koncentrationsrisiko i kryptosektoren. Når blot ét angreb kan resultere i $1,5 mia. i tab, understreger det, at selv velkapitaliserede børser skal have robuste nødplaner og diversificeret opbevaringsstrategi. For danske og nordiske kryptoinvestorer er det en påmindelse om, at digital suverænitet og egenkontrol over aktiver via hardware wallets reducerer eksponeringen over for børsrisiko.

Regulatoriske konsekvenser for kryptobørser

Bybit-angrebet har bidraget til at accelerere diskussionen om skærpede sikkerhedskrav til kryptobørser. Juridiske analytikere fra Paul Hastings noterede, at angrebet “nærer igangværende debatter om sikkerhed, ansvar og behovet for yderligere reguleringsovervågning i det digitale aktivunivers.”

I EU er kryptobørser siden januar 2025 underlagt MiCA-forordningen (Markets in Crypto-Assets Regulation), der stiller krav til kapitalreserver, risikostyring og cybersikkerhed. Angrebet på Bybit har vist, at selvom MiCA sætter en baseline for compliance, er regulatoriske krav alene ikke tilstrækkelige til at forhindre sofistikerede supply chain-angreb.

For kryptobørser, der opererer i Norden og EU, betyder det, at sikkerhedsinvesteringer ud over minimumsforpligtelserne er nødvendige. EU’s Cyber Resilience Act pålægger ligeledes skærpede krav til softwarekomponenter, herunder tredjepartsintegrationer som Safe{Wallet} var i Bybits tilfælde.

Chainalysis’ samarbejde med Bybit om at spore og fryse midler viser, at den private sektors blockchain-analysefirmaer spiller en stadig vigtigere rolle i at kompensere for lovgivningsmyndighedernes begrænsede tekniske kapacitet inden for kryptosporing. TRM Labs’ detaljerede analyse af hvidvaskningsforløbet dokumenterer kompleksiteten i sådanne efterforskninger.

Sikkerhedslæring: Hvad kryptobørser bør gøre

Bybit-angrebet leverede en hård, men værdifuld lektion til hele kryptosektoren. Sikkerhedseksperterne hos Trail of Bits identificerede fire centrale anbefalinger som direkte svar på angrebet.

Isolér signeringsinfrastrukturen

Den primære lærdom er, at signeringsprocessen for store koldlommetransaktioner skal ske på dedikerede, air-gapped maskiner, der er fuldstændig isoleret fra internettet og tredjepartsinfrastruktur. En kompromitteret Safe{Wallet}-udviklermaskine burde ikke have kunnet påvirke Bybits signeringsproces.

Verificér tredjepartsintegrationer løbende

Supply chain-angreb som Bybit-hacket kræver, at organisationer løbende auditerer og verificerer integriteten af al kode og infrastruktur fra tredjeparter. Det gælder ikke blot ved implementering, men som en kontinuerlig proces. Kode-integritetstjek, softwaresignaturverifikation og regelmæssige penetrationstests af tredjepartsintegrationer er minimumskrav.

Wiz Researchs rapport om TraderTraitor understreger, at gruppen er ekstremt sofistikeret og anvender en kombination af social engineering og teknisk kompromittering. Wiz’ tekniske analyse af TraderTraitor anbefaler bl.a., at organisationer implementerer stærk adgangskontrol og løbende overvågning af alle privilegerede systemadgange.

Hvad betyder det for danske kryptoinvestorer?

Bybit-angrebet har direkte relevans for danske og nordiske kryptoinvestorer. Selvom Bybit kompenserede alle brugere fuldt ud i dette tilfælde, er der ingen garanti for, at det vil ske ved fremtidige angreb på andre børser.

DNV’s 2026-rapport om nordisk cyberresiliens dokumenterede 41 cyberangreb mod danske organisationer i 2025, med Sverige som hårdest ramt med 60 hændelser. Rapporten påpeger, at 54 procent af ledere inden for kritisk infrastruktur stadig betragte national cyberresiliens som “andres ansvar.” Den holdning er farlig i en verden, hvor statslige aktører systematisk angriber finansielle institutioner.

For den individuelle dansker med kryptoinvesteringer er den praktiske konklusion klar:

  • Opbevar ikke store kryptobeløb på centraliserede børser i længere tid
  • Brug hardware wallets til langtidsopbevaring
  • Diversificér opbevaring på tværs af mehrere wallets og løsninger
  • Forstå, at kryptobørser ikke er bankindskud dækket af indskydergarantifonden
  • Følg med i sikkerhedsopdateringer fra de børser, du bruger

Sopra Sterias State of Cyber Security 2026-rapport for Norden viser, at 44,4 procent af sikkerhedshændelser i nordiske virksomheder var phishing-relaterede. Kryptoinvestorer er et særligt attraktivt mål for phishing-kampagner fra Lazarus Group og andre grupper, der bruger social engineering til at kompromittere individuelle konti.

Fem forudsigelser for kryptosikkerhed 2026-2027

Baseret på mønstrene fra Bybit-angrebet og den bredere trusselslandskab er her fem analysebaserede forudsigelser for kryptosikkerhed i de kommende år.

  1. Flere supply chain-angreb mod kryptobørser. Bybit-angrebet demonstrerede effektiviteten af at ramme tredjepartsinfrastruktur frem for børser direkte. Forvent tilsvarende angreb mod andre wallet-løsninger og DeFi-infrastruktur i 2026-2027.
  2. Skærpede krav til tredjepartsaudit under MiCA og DORA. EU-regulatorer vil sandsynligvis skærpe kravene til kryptobørsers supply chain-risikostyring som direkte reaktion på Bybit-typen af angreb.
  3. Nordkorea fortsætter med rekordstore angreb. Med $2,02 mia. stjålet i 2025 og ingen tegn på at bremse, forventes Lazarus Group at fortsætte med at målrette kryptosektoren som primær finansieringskilde.
  4. Hurtigere cross-industry samarbejde om frysning af midler. Bybit-responsen etablerede en model for hurtig koordination. Kryptobranchen vil bygge mere formaliserede protokoller for hurtig reaktion på store tyveriangreb.
  5. Hardware wallet-adoption stiger markant. Bybit-sagen vil accelerere skiftet mod self-custody-løsninger, særligt i markedet for retail-investorer i Europa og Norden.

Relateret dækning

Læs også

Ofte stillede spørgsmål

Hvad var Bybit-hacket?

Bybit-hacket var et cyberangreb den 21. februar 2025 mod kryptobørsen Bybit, hvor hackere stjal $1,5 mia. i Ethereum og relaterede tokens. Det er det største kryptotyveri i historien og blev attribueret til Nordkoreas statssponsorerede Lazarus Group.

Hvordan skete Bybit-angrebet teknisk set?

Angriberne kompromitterede en udviklermaskine hos Safe{Wallet}, den tredjepartsplatform Bybit brugte til multisig-godkendelse. Herfra injicerede de ondsindet JavaScript-kode i signeringsprocessen, som manipulerede en legitim transaktion til at overføre midlerne til hackernes egne wallets.

Fik Bybit-brugere deres penge tilbage?

Ja. Bybit lukkede ETH-gabet inden for 72 timer ved at rejse $1,23 mia. via nødlån, whale-indskud og OTC-handler med partnere. En uafhængig revision bekræftede, at alle brugerindskud var fuldt dækkede. Brugerne led direkte intet tab.

Hvem stjal pengene fra Bybit?

FBI bekræftede den 26. februar 2025, at angrebet stod Nordkorea bag via hackergruppen TraderTraitor, der er en undergren af det statssponsorerede Lazarus Group. Lazarus Group finansierer ifølge amerikanske efterretningstjenester Nordkoreas ballistiske missilprogram med stjålne kryptomidler.

Hvor meget af de stjålne midler blev genvundet?

Kun $42,89 mio. svarende til ca. 2,9 procent af de stjålne midler, blev indefrosset i de første dage. Over $400 mio. var allerede hvidvasket inden for fem dage. De resterende midler forblev spredt over tusindvis af blockchain-adresser og sporedes aktivt af Chainalysis, TRM Labs og FBI.

Hvad kan danske kryptoinvestorer lære af Bybit-angrebet?

Den vigtigste lærdom er at minimere opbevaring af store kryptobeløb på centraliserede børser og i stedet bruge hardware wallets til langtidsopbevaring. Ingen børs er helt sikker mod sofistikerede statslige aktører som Lazarus Group. Diversificering af opbevaringsstrategi reducerer risikoen for totalt tab.

Hvad er Lazarus Groups samlede kryptotyveri?

Ifølge Chainalysis har Nordkorea via Lazarus Group og relaterede aktører stjålet kryptovaluta svarende til $6,75 mia. siden 2017. I 2025 alene stjal gruppen $2,02 mia., en stigning på 51 procent sammenlignet med 2024. Bybit-angrebet på $1,5 mia. udgjorde den største enkeltbegivenhed i dette forløb.

Denne guide viser dig præcis, hvordan du eliminerer SQL injection i Node.js trin for trin. Du lærer at bruge parameteriserede forespørgsler med mysql2 og pg, sikre ORMs som Prisma, inputvalidering med Zod og express-validator, og en række avancerede teknikker der beskytter din applikation i produktionsmiljøet. Guiden er baseret på research fra 2025-2026 og dækker den kode, du skriver i dag.

Hvad er SQL injection, og hvorfor er det farligt i 2026?

SQL injection opstår, når brugerinput flettes direkte ind i en SQL-forespørgsel som tekst. Databasemotoren kan ikke skelne mellem forespørgselslogik og brugerdata, og en angriber kan dermed injicere sin egen SQL-kode. Konsekvenserne spænder fra datalæk til fuldstændig overtagelse af serveren.

Tag dette Node.js-eksempel, der aldrig må bruges i produktion:

// FARLIGT - Brug aldrig dette mønster
const username = req.body.username;
const query = `SELECT * FROM users WHERE username = '${username}'`;
connection.query(query, (err, results) => {
  // En angriber sender: admin' OR '1'='1
  // SQL bliver: SELECT * FROM users WHERE username = 'admin' OR '1'='1'
  // Resultat: Alle brugere returneres
});

En angriber indsender admin' OR '1'='1 som brugernavn, og forespørgslen returnerer alle brugere i databasen. Med et lidt mere avanceret payload som '; DROP TABLE users; -- sletter angriberen hele tabellen.

AngrebstypeEksempel-payloadEffektLøsning
Classic injection' OR '1'='1Omgå loginParameteriserede queries
UNION-baseret' UNION SELECT password FROM users--DataudtrækParameteriserede queries og ORM
Blind (boolean)' AND 1=1--Skjult dataudtrækInput-validering og WAF
Time-based blind'; WAITFOR DELAY '0:0:5'--Bekræft sårbarhedQuery-timeout og parameterisering
Stacked queries'; INSERT INTO admin VALUES(...)--DatamanipulationDeaktiver multiple statements
Out-of-band' EXEC xp_cmdshell('ping attacker.com')--KommandoudførelseMindste privilegium og WAF

Forudsætninger og versioner

Inden du begynder, skal du have følgende installeret og konfigureret:

  • Node.js 22.x LTS eller nyere (aktiv support i 2026)
  • npm 10.x eller nyere
  • MySQL 8.x eller PostgreSQL 16.x (eksempler dækker begge)
  • Grundlæggende kendskab til Express.js og async/await
  • En lokal MySQL/PostgreSQL-installation til testformål

Pakker du installerer i denne guide:

PakkeFormålInstallation
expressHTTP-servernpm install express
mysql2MySQL-driver med parameteriseringnpm install mysql2
pgPostgreSQL-drivernpm install pg
@prisma/clientType-sikker ORMnpm install @prisma/client
prismaPrisma CLI (dev-afhængighed)npm install --save-dev prisma
zodSchema-valideringnpm install zod
express-validatorExpress middleware-valideringnpm install express-validator
dotenvMiljøvariablernpm install dotenv

Trin 1: Projektopsætning

Start med at oprette et rent projekt med den korrekte mappestruktur. En god struktur gør det nemmere at håndhæve sikkerhedsregler konsekvent på tværs af al kode.

mkdir node-sql-sikker && cd node-sql-sikker
npm init -y
npm install express mysql2 pg @prisma/client zod express-validator dotenv
npm install --save-dev prisma nodemon

# Opret projektstruktur
mkdir -p src/{routes,middleware,db,validators}
touch src/app.js src/db/mysql.js src/db/postgres.js .env

Tilføj følgende til din .env-fil. Commit aldrig denne fil til versionsstyring:

# .env
DB_HOST=localhost
DB_PORT=3306
DB_USER=app_user          # Brug IKKE root
DB_PASSWORD=dit_stærke_password
DB_NAME=node_sikker_db
DB_POOL_MAX=10

PG_HOST=localhost
PG_PORT=5432
PG_USER=app_pg_user
PG_PASSWORD=dit_postgres_password
PG_DATABASE=node_sikker_pg
echo ".env" >> .gitignore
echo "node_modules/" >> .gitignore

Trin 2: Forstå den sårbare kode

Inden du retter et problem, skal du forstå det til bunds. Her er tre typiske sårbare mønstre du ofte ser i Node.js-kode, og som du skal genkende med det samme:

// FARLIGE MØNSTRE - Vis kun til undervisningsformål

// Mønster 1: Template literals med brugerinput
const søg = req.query.q;
db.query(`SELECT * FROM produkter WHERE navn LIKE '%${søg}%'`);

// Mønster 2: String-sammensætning
const id = req.params.id;
db.query("SELECT * FROM brugere WHERE id = " + id);

// Mønster 3: Ufiltreret sortering (kan ikke parameteriseres)
const kolonne = req.query.sortBy;
db.query(`SELECT * FROM ordrer ORDER BY ${kolonne} ASC`);
// Angriber sender: kolonne = "1; DROP TABLE ordrer; --"

Det tredje mønster er særligt svært at opdage, fordi kolonnenavne ikke kan parameteriseres direkte. Du skal i stedet bruge allowlisting (trin 10) til sorteringskolonner og andre dynamiske strukturer.

Trin 3: Parameteriserede forespørgsler med mysql2

mysql2-pakken er den anbefalede MySQL-driver til Node.js i 2026. Den understøtter parameteriserede forespørgsler som standard og bruger ?-pladsholdere. Værdier sendes som et separat array, aldrig flettet ind i SQL-strengen.

Opret din database-forbindelsesopsætning i src/db/mysql.js:

// src/db/mysql.js
require('dotenv').config();
const mysql = require('mysql2/promise');

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT),
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  connectionLimit: parseInt(process.env.DB_POOL_MAX),
  multipleStatements: false,  // KRITISK: deaktiver multiple statements
  waitForConnections: true,
  queueLimit: 0,
});

module.exports = pool;

Brug nu parameteriserede forespørgsler i alle dine routes:

// src/routes/brugere.js
const express = require('express');
const pool = require('../db/mysql');
const router = express.Router();

// SIKKERT: Parameteriseret forespørgsel med ?-pladsholder
router.get('/bruger/:id', async (req, res) => {
  const { id } = req.params;

  try {
    // id indsættes som parameter, ikke i SQL-strengen
    const [rows] = await pool.execute(
      'SELECT id, navn, email FROM brugere WHERE id = ?',
      [id]
    );

    if (rows.length === 0) {
      return res.status(404).json({ fejl: 'Bruger ikke fundet' });
    }

    res.json(rows[0]);
  } catch (err) {
    // Afslør ALDRIG databasefejl til klienten
    console.error('Databasefejl:', err.message);
    res.status(500).json({ fejl: 'Intern serverfejl' });
  }
});

// SIKKERT: Søgning med LIKE
router.get('/søg', async (req, res) => {
  const { q } = req.query;

  try {
    // Indpak wildcards på serversiden, ikke klientsiden
    const søgterm = `%${q}%`;
    const [rows] = await pool.execute(
      'SELECT id, navn, email FROM brugere WHERE navn LIKE ?',
      [søgterm]
    );
    res.json(rows);
  } catch (err) {
    console.error('Søgningsfejl:', err.message);
    res.status(500).json({ fejl: 'Intern serverfejl' });
  }
});

module.exports = router;

Bemærk forskellen på pool.query() og pool.execute(): execute() bruger prepared statements på protokolniveau, hvilket giver det stærkeste forsvar. Brug altid execute() med brugerinput.

Trin 4: Parameteriserede forespørgsler med pg (PostgreSQL)

PostgreSQL bruger $1, $2, $3-pladsholdere i stedet for ?. pg-pakken håndterer parameterisering korrekt, når du adskiller SQL-tekst fra parametre.

// src/db/postgres.js
require('dotenv').config();
const { Pool } = require('pg');

const pgPool = new Pool({
  host: process.env.PG_HOST,
  port: parseInt(process.env.PG_PORT),
  user: process.env.PG_USER,
  password: process.env.PG_PASSWORD,
  database: process.env.PG_DATABASE,
  max: 10,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

module.exports = pgPool;

// src/routes/produkter.js
const pgPool = require('../db/postgres');
const router = require('express').Router();

router.get('/produkt/:id', async (req, res) => {
  const { id } = req.params;

  try {
    // PostgreSQL-parameterisering med $1-pladsholder
    const result = await pgPool.query(
      'SELECT id, navn, pris, kategori FROM produkter WHERE id = $1',
      [id]
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ fejl: 'Produkt ikke fundet' });
    }

    res.json(result.rows[0]);
  } catch (err) {
    console.error('PostgreSQL-fejl:', err.message);
    res.status(500).json({ fejl: 'Intern serverfejl' });
  }
});

// Indsæt data sikkert med flere parametre
router.post('/produkt', async (req, res) => {
  const { navn, pris, kategori } = req.body;

  try {
    const result = await pgPool.query(
      'INSERT INTO produkter (navn, pris, kategori) VALUES ($1, $2, $3) RETURNING id',
      [navn, pris, kategori]
    );

    res.status(201).json({ id: result.rows[0].id });
  } catch (err) {
    console.error('Indsætningsfejl:', err.message);
    res.status(500).json({ fejl: 'Intern serverfejl' });
  }
});

Trin 5: Prisma ORM til automatisk parameterisering

Prisma er en type-sikker ORM der automatisk parameteriserer alle forespørgsler. Du skriver aldrig rå SQL i normale operationer, og Prisma håndterer alle databindinger korrekt. Det er en af de stærkeste beskyttelser mod SQL injection, fordi fejlmuligheden elimineres strukturelt i stedet for at afhænge af, at udvikleren husker at gøre det korrekt.

Opsæt Prisma til MySQL:

# Initialiser Prisma
npx prisma init --datasource-provider mysql

# Generer Prisma Client efter schema-ændringer
npx prisma generate

# Push schema til databasen (kun i udvikling)
npx prisma db push
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Bruger {
  id        Int      @id @default(autoincrement())
  navn      String   @db.VarChar(100)
  email     String   @unique @db.VarChar(255)
  oprettet  DateTime @default(now())

  @@map("brugere")
}

model Produkt {
  id        Int      @id @default(autoincrement())
  navn      String   @db.VarChar(200)
  pris      Decimal  @db.Decimal(10, 2)
  kategori  String   @db.VarChar(50)

  @@map("produkter")
}
// src/routes/prisma-brugere.js
const { PrismaClient } = require('@prisma/client');
const router = require('express').Router();
const prisma = new PrismaClient();

// SIKKERT: Prisma parameteriserer automatisk via query-API
router.get('/bruger/:id', async (req, res) => {
  const id = parseInt(req.params.id);

  if (isNaN(id)) {
    return res.status(400).json({ fejl: 'Ugyldigt ID' });
  }

  try {
    const bruger = await prisma.bruger.findUnique({
      where: { id },
      select: { id: true, navn: true, email: true, oprettet: true }
    });

    if (!bruger) {
      return res.status(404).json({ fejl: 'Bruger ikke fundet' });
    }

    res.json(bruger);
  } catch (err) {
    console.error('Prisma-fejl:', err.message);
    res.status(500).json({ fejl: 'Intern serverfejl' });
  }
});

// Sikker søgning med Prisma - automatisk escaped
router.get('/søg', async (req, res) => {
  const { q } = req.query;

  try {
    const brugere = await prisma.bruger.findMany({
      where: {
        navn: {
          contains: q,
          mode: 'insensitive'
        }
      },
      select: { id: true, navn: true, email: true }
    });

    res.json(brugere);
  } catch (err) {
    console.error('Søgningsfejl:', err.message);
    res.status(500).json({ fejl: 'Intern serverfejl' });
  }
});

// Raw SQL med Prisma - brug KUN tagget template literal
router.get('/avanceret/:kategori', async (req, res) => {
  const { kategori } = req.params;

  try {
    // prisma.$queryRaw med tagged template er sikker
    // Brug ALDRIG prisma.$queryRawUnsafe() med brugerinput
    const produkter = await prisma.$queryRaw`
      SELECT id, navn, pris
      FROM produkter
      WHERE kategori = ${kategori}
      ORDER BY pris ASC
    `;

    res.json(produkter);
  } catch (err) {
    console.error('Raw query fejl:', err.message);
    res.status(500).json({ fejl: 'Intern serverfejl' });
  }
});

Trin 6: Input-validering med Zod

Parameteriserede forespørgsler stopper SQL injection på databaseniveau. Input-validering stopper det allerede ved API-grænsen, inden data overhovedet når databaselaget. Disse to lag supplerer hinanden og må ikke erstatte hinanden. Zod er et TypeScript-first schema-valideringsbibliotek der fungerer fremragende med ren JavaScript. Det validerer ikke bare at en værdi er til stede, men kontrollerer type, format, længde og begrænsninger præcist.

// src/validators/bruger-validator.js
const { z } = require('zod');

const brugerSøgSchema = z.object({
  q: z
    .string()
    .min(2, 'Søgning skal være mindst 2 tegn')
    .max(100, 'Søgning må maksimalt være 100 tegn')
    .regex(/^[a-zA-ZæøåÆØÅ0-9\s\-\.]+$/, 'Ugyldige tegn i søgning'),
});

const brugerIdSchema = z.object({
  id: z
    .string()
    .regex(/^\d+$/, 'ID skal være et heltal')
    .transform(Number)
    .refine((n) => n > 0 && n < 2147483647, 'ID uden for gyldigt interval'),
});

const opretBrugerSchema = z.object({
  navn: z
    .string()
    .min(2, 'Navn skal være mindst 2 tegn')
    .max(100, 'Navn må maksimalt være 100 tegn')
    .regex(/^[a-zA-ZæøåÆØÅ\s\-']+$/, 'Ugyldige tegn i navn'),
  email: z
    .string()
    .email('Ugyldig e-mailadresse')
    .max(255, 'E-mail er for lang')
    .toLowerCase(),
  alder: z
    .number()
    .int('Alder skal være et heltal')
    .min(13, 'Minimum alder er 13')
    .max(120, 'Ugyldig alder'),
});

module.exports = { brugerSøgSchema, brugerIdSchema, opretBrugerSchema };

// src/middleware/valider.js
const { ZodError } = require('zod');

function valider(schema, kilde = 'body') {
  return (req, res, next) => {
    try {
      const data = schema.parse(req[kilde]);
      req[kilde] = data; // Udskift med valideret og transformeret data
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        return res.status(400).json({
          fejl: 'Valideringsfejl',
          detaljer: err.errors.map((e) => ({
            felt: e.path.join('.'),
            besked: e.message,
          })),
        });
      }
      next(err);
    }
  };
}

module.exports = valider;

Brug middleware i dine routes:

// src/routes/sikker-brugere.js
const router = require('express').Router();
const pool = require('../db/mysql');
const valider = require('../middleware/valider');
const { brugerSøgSchema, opretBrugerSchema } = require('../validators/bruger-validator');

// Valideringsfejl returneres INDEN databasekaldet sker
router.get('/søg', valider(brugerSøgSchema, 'query'), async (req, res) => {
  const { q } = req.query; // Garanteret valideret og renset

  const søgterm = `%${q}%`;
  const [rows] = await pool.execute(
    'SELECT id, navn, email FROM brugere WHERE navn LIKE ?',
    [søgterm]
  );
  res.json(rows);
});

router.post('/opret', valider(opretBrugerSchema), async (req, res) => {
  const { navn, email, alder } = req.body; // Valideret og transformeret

  const [result] = await pool.execute(
    'INSERT INTO brugere (navn, email, alder) VALUES (?, ?, ?)',
    [navn, email, alder]
  );

  res.status(201).json({ id: result.insertId });
});

module.exports = router;

Trin 7: Input-validering med express-validator

express-validator er alternativet til Zod for Express.js-projekter. Det tilbyder en kæde-baseret API der er tæt integreret med Express middleware-mønsteret og er et godt valg, hvis du allerede har et eksisterende Express-projekt.

// src/routes/produkt-regler.js
const { body, param, query, validationResult } = require('express-validator');

// Valideringsregler som middleware-arrays
const søgRegler = [
  query('q')
    .trim()
    .notEmpty().withMessage('Søgeterm er påkrævet')
    .isLength({ min: 2, max: 100 }).withMessage('Søgeterm skal være 2-100 tegn')
    .matches(/^[a-zA-ZæøåÆØÅ0-9\s\-\.]+$/).withMessage('Ugyldige tegn i søgeterm'),
];

const idRegler = [
  param('id')
    .isInt({ min: 1, max: 2147483647 }).withMessage('ID skal være et positivt heltal')
    .toInt(),
];

const produktRegler = [
  body('navn')
    .trim()
    .notEmpty().withMessage('Navn er påkrævet')
    .isLength({ min: 2, max: 200 }).withMessage('Navn skal være 2-200 tegn'),
  body('pris')
    .isFloat({ min: 0.01, max: 9999999.99 }).withMessage('Pris skal være et positivt tal')
    .toFloat(),
  body('kategori')
    .trim()
    .notEmpty().withMessage('Kategori er påkrævet')
    .isIn(['elektronik', 'tøj', 'mad', 'sport']).withMessage('Ugyldig kategori'),
];

// Middleware til at håndtere valideringsfejl
function tjekFejl(req, res, next) {
  const fejl = validationResult(req);
  if (!fejl.isEmpty()) {
    return res.status(400).json({
      fejl: 'Valideringsfejl',
      detaljer: fejl.array().map((e) => ({
        felt: e.path,
        besked: e.msg,
      })),
    });
  }
  next();
}

module.exports = { søgRegler, idRegler, produktRegler, tjekFejl };

// Brug i route - alle tre middleware kædes
// router.post('/produkt', ...produktRegler, tjekFejl, håndterProdukt)

Trin 8: Mindste privilegium for databasebrugeren

Selv med perfekte parameteriserede forespørgsler skal du begrænse skaden, hvis et angreb lykkes. Mindste privilegium-princippet betyder, at din applikationsbruger kun har de databaserettigheder, der er strengt nødvendige. En angriber der eksekuterer SQL via din applikation kan kun gøre det, som din databasebruger har tilladelse til.

-- Kør disse kommandoer som MySQL root
-- Opret applikationsbruger med stærkt password
CREATE USER 'app_user'@'localhost' IDENTIFIED WITH caching_sha2_password BY 'StærktPassword2026!';

-- Giv kun de nødvendige rettigheder til specifikke tabeller
GRANT SELECT, INSERT, UPDATE ON node_sikker_db.brugere TO 'app_user'@'localhost';
GRANT SELECT, INSERT, UPDATE ON node_sikker_db.produkter TO 'app_user'@'localhost';
GRANT SELECT ON node_sikker_db.kategorier TO 'app_user'@'localhost';

-- Ingen DROP, DELETE på følsomme tabeller - implementer soft delete i stedet
-- Ingen GRANT OPTION - brugeren kan ikke give rettigheder videre
-- Ingen FILE-rettighed - forhindrer filsystem-adgang via SQL

FLUSH PRIVILEGES;

-- Verificer rettigheder
SHOW GRANTS FOR 'app_user'@'localhost';

For PostgreSQL:

-- PostgreSQL mindste privilegium
CREATE USER app_pg_user WITH PASSWORD 'StærktPgPassword2026!';

GRANT CONNECT ON DATABASE node_sikker_pg TO app_pg_user;
GRANT USAGE ON SCHEMA public TO app_pg_user;
GRANT SELECT, INSERT, UPDATE ON TABLE brugere TO app_pg_user;
GRANT SELECT, INSERT, UPDATE ON TABLE produkter TO app_pg_user;
GRANT SELECT ON TABLE kategorier TO app_pg_user;

-- Giv adgang til sequences (påkrævet for INSERT med autoincrement)
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_pg_user;

-- Verificer
\dp brugere

Trin 9: Deaktiver multiple statements

Multiple statements-funktionen tillader at sende flere SQL-kommandoer adskilt af semikolon i en enkelt forespørgsel. Det er præcis det, der muliggør angreb som '; DROP TABLE brugere; --. mysql2 deaktiverer dette som standard, men du skal eksplicit bekræfte det i din konfiguration og aldrig overskrive det.

// Eksplicit deaktivering i mysql2 pool-konfiguration
const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,

  // KRITISK SIKKERHEDSINDSTILLING - lad denne stå som false
  multipleStatements: false,

  // Begræns forbindelsestimeout
  connectTimeout: 10000,

  // Aktiver SSL i produktion
  ssl: process.env.NODE_ENV === 'production' ? {
    rejectUnauthorized: true,
  } : undefined,
});

// Kør denne test ved opstart for at bekræfte konfigurationen
async function testSikkerhedKonfiguration() {
  try {
    await pool.execute('SELECT 1; SELECT 2');
    console.error('ADVARSEL: Multiple statements er aktiveret - ret dette med det samme!');
    process.exit(1);
  } catch (err) {
    console.log('OK: Multiple statements er korrekt deaktiveret');
  }
}

testSikkerhedKonfiguration();

Trin 10: Allowlisting til dynamiske SQL-strukturer

Kolonnenavne, tabelnavne og sorteringsretninger kan ikke parameteriseres. En angriber der kan styre en ORDER BY-klausul injicerer UNION-angreb eller forårsager fejl der afslører databasestrukturen. Løsningen er allowlisting: du definerer præcist hvilke værdier der er gyldige, og afviser alt andet.

// src/middleware/allowlist.js

const TILLADTE_SORTERINGS_KOLONNER = {
  brugere: ['navn', 'email', 'oprettet'],
  produkter: ['navn', 'pris', 'kategori', 'oprettet'],
};

const TILLADTE_RETNINGER = ['ASC', 'DESC'];

function validerSortering(tabel, kolonne, retning) {
  const tilladeKolonner = TILLADTE_SORTERINGS_KOLONNER[tabel];

  if (!tilladeKolonner) {
    throw new Error(`Ukendt tabel: ${tabel}`);
  }

  if (!tilladeKolonner.includes(kolonne)) {
    throw new Error(`Ikke-tilladt sorteringskolonne: ${kolonne}`);
  }

  const normalRetning = retning.toUpperCase();
  if (!TILLADTE_RETNINGER.includes(normalRetning)) {
    throw new Error(`Ikke-tilladt sorteringsretning: ${retning}`);
  }

  return { kolonne, retning: normalRetning };
}

// Brug i route
router.get('/produkter', async (req, res) => {
  const { sortBy = 'navn', retning = 'ASC' } = req.query;

  try {
    // Valider mod allowlist FØR brug i SQL
    const { kolonne, retning: sikkerRetning } = validerSortering(
      'produkter',
      sortBy,
      retning
    );

    // Nu er det sikkert at indsætte direkte - de er bekræftet fra kontrolleret liste
    const [rows] = await pool.execute(
      `SELECT id, navn, pris FROM produkter ORDER BY ${kolonne} ${sikkerRetning}`,
      []
    );

    res.json(rows);
  } catch (err) {
    if (err.message.startsWith('Ikke-tilladt') || err.message.startsWith('Ukendt')) {
      return res.status(400).json({ fejl: err.message });
    }
    console.error('Databasefejl:', err.message);
    res.status(500).json({ fejl: 'Intern serverfejl' });
  }
});

module.exports = { validerSortering };

Trin 11: Fejlhåndtering der ikke lækker SQL-fejl

En SQL-fejlmeddelelse som You have an error in your SQL syntax near '' at line 1 fortæller en angriber, at injection-forsøget nåede databasen. Din fejlhåndtering skal logge detaljerne internt og kun returnere generiske beskeder til klienten.

// src/middleware/fejlhåndtering.js

function globalFejlhåndterer(err, req, res, next) {
  const fejlId = Math.random().toString(36).substring(2, 10);

  // Log den fulde fejl internt - aldrig til klienten
  console.error({
    fejlId,
    timestamp: new Date().toISOString(),
    metode: req.method,
    sti: req.path,
    fejltype: err.constructor.name,
    besked: err.message,
    stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
  });

  // MySQL fejlkoder starter med ER_
  if (err.code && err.code.startsWith('ER_')) {
    return res.status(500).json({
      fejl: 'Databaseoperation mislykkedes',
      fejlId,
    });
  }

  // PostgreSQL unique constraint violation
  if (err.code && err.code === '23505') {
    return res.status(409).json({
      fejl: 'Ressourcen eksisterer allerede',
    });
  }

  res.status(err.status || 500).json({
    fejl: process.env.NODE_ENV === 'production'
      ? 'Intern serverfejl'
      : err.message,
    fejlId,
  });
}

module.exports = globalFejlhåndterer;

Trin 12: Test din applikation med sqlmap

sqlmap er det mest brugte open source-værktøj til at opdage SQL injection-sårbarheder. Kør det mod din lokale udviklingsserver for at verificere, at dine forsvar holder. Brug det kun mod dine egne applikationer eller med eksplicit skriftlig tilladelse fra ejeren.

# Installer sqlmap
pip3 install sqlmap

# Start din Node.js testserver lokalt
node src/app.js &

# Test GET-endpoint mod søge-parameter
sqlmap -u "http://localhost:3000/api/brugere/søg?q=test" \
  --level=3 \
  --risk=2 \
  --batch \
  --random-agent

# Forventet output fra en korrekt sikret applikation:
# [INFO] GET parameter 'q' does not seem to be injectable
# [INFO] all tested parameters do not appear to be injectable

# Test POST-endpoint med JSON-body
sqlmap -u "http://localhost:3000/api/produkter" \
  --data='{"navn":"test","pris":10,"kategori":"elektronik"}' \
  --content-type="application/json" \
  --level=3 \
  --batch

# Stop testserver
kill %1

Kør sqlmap som en del af din CI/CD-pipeline mod et stagingmiljø ved hvert deployment. Det tager typisk 2-5 minutter for en enkel API og giver maskinel verifikation af dine forsvar.

8 hyppige fejl der åbner for SQL injection

Selv erfarne udviklere begår disse fejl. Kend dem, så du kan opdage dem under code reviews og pull request-gennemgang.

#FejlSårbar kodeSikker løsning
1Template literals med brugerinput`WHERE id = ${id}`WHERE id = ? med parameter-array
2String-sammensætning"WHERE navn = '" + navn + "'"Parameteriserede forespørgsler
3Dynamisk ORDER BY uden allowlist`ORDER BY ${req.query.sort}`Valider mod foruddefineret liste
4Escaped input i stedet for parameteriseringmysql.escape(input) i SQL-strengBrug ?-pladsholdere konsekvent
5Databasefejl eksponeret til klientres.json({ fejl: err.message })Log internt, returner generisk besked
6Prisma raw query med brugerinputprisma.$queryRawUnsafe(sql)Brug prisma.$queryRaw`...` syntax
7Ingen validering af datatyperAccepter alle strenge som IDValider at ID er positivt heltal med Zod
8Multiple statements aktiveretmultipleStatements: trueAltid multipleStatements: false

Avancerede teknikker

Stored procedures som ekstra isolationslag

Stored procedures isolerer SQL-logik i databasen og reducerer risikoen for, at applikationskoden introducerer injection-sårbarheder. Applikationen kalder kun procedurenavnet med parametre og ser aldrig den underliggende SQL. Det er særligt nyttigt, når applikationsudviklerne og databaseadministratorerne er separate teams.

-- Opret stored procedure i MySQL
DELIMITER //
CREATE PROCEDURE HentBruger(IN p_id INT)
BEGIN
  SELECT id, navn, email, oprettet
  FROM brugere
  WHERE id = p_id;
END //
DELIMITER ;

-- Giv applikationsbruger kun EXECUTE-rettighed (ikke direkte tabeladgang)
GRANT EXECUTE ON PROCEDURE node_sikker_db.HentBruger TO 'app_user'@'localhost';
REVOKE SELECT ON node_sikker_db.brugere FROM 'app_user'@'localhost';
// Kald stored procedure fra Node.js
router.get('/bruger/:id', async (req, res) => {
  const id = parseInt(req.params.id);
  if (isNaN(id) || id < 1) {
    return res.status(400).json({ fejl: 'Ugyldigt ID' });
  }

  const [rows] = await pool.execute('CALL HentBruger(?)', [id]);
  const bruger = rows[0][0]; // Stored procedures returnerer nested array

  if (!bruger) {
    return res.status(404).json({ fejl: 'Bruger ikke fundet' });
  }

  res.json(bruger);
});

Runtime-beskyttelse med Aikido Firewall

Aikido Firewall er en open source agent-baseret sikkerhedsplatform til Node.js der blokerer SQL injection-angreb i realtid. Det fungerer som et ekstra netværksniveau under din applikation og fanger angreb der slipper igennem, selv hvis koden indeholder fejl. Det er ikke en erstatning for korrekt parameterisering, men et sikkerhedsnet.

npm install @aikidosec/firewall
// KRITISK: Importer Aikido FØR alle andre moduler på første linje
require('@aikidosec/firewall');

const express = require('express');
// ... resten af din applikation

Automatiseret sikkerhedstjek i CI/CD

SQL injection introduceres også via kompromitterede npm-pakker. Kør automatiske sikkerhedstjek ved hvert commit med npm audit og Snyk:

# .github/workflows/sikkerhed.yml
name: Sikkerhedstjek

on: [push, pull_request]

jobs:
  sikkerhed:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Installer Node.js 22
        uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Installer afhængigheder
        run: npm ci

      - name: npm audit - fejl ved høj sværhedsgrad
        run: npm audit --audit-level=high

      - name: Snyk sikkerhedstjek
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

Komplet arbejdende projekt

Her er den fulde src/app.js der samler alle teknikker fra guiden til en produktionsklar applikation:

// src/app.js - Komplet sikker Node.js API mod SQL injection
require('@aikidosec/firewall'); // Altid første linje
require('dotenv').config();

const express = require('express');
const helmet = require('helmet');
const sikkerBrugereRouter = require('./routes/sikker-brugere');
const produkterRouter = require('./routes/produkter');
const globalFejlhåndterer = require('./middleware/fejlhåndtering');

const app = express();

// Sikkerhedsheadere (CSP, HSTS, X-Frame-Options, osv.)
app.use(helmet());

// Body parsing med størrelsesbegrænsning mod DoS
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));

// Routes
app.use('/api/brugere', sikkerBrugereRouter);
app.use('/api/produkter', produkterRouter);

// 404 håndtering
app.use((req, res) => {
  res.status(404).json({ fejl: 'Ressource ikke fundet' });
});

// Global fejlhåndtering - altid sidst
app.use(globalFejlhåndterer);

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Sikker server kører på port ${PORT}`);
});

module.exports = app;

Opsæt testdatabasen og kør projektet:

# Sæt testdatabasetabeller op (MySQL)
mysql -u root -p << 'ENDSQL'
CREATE DATABASE IF NOT EXISTS node_sikker_db;
USE node_sikker_db;
CREATE TABLE IF NOT EXISTS brugere (
  id INT AUTO_INCREMENT PRIMARY KEY,
  navn VARCHAR(100) NOT NULL,
  email VARCHAR(255) NOT NULL UNIQUE,
  alder INT,
  oprettet DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS produkter (
  id INT AUTO_INCREMENT PRIMARY KEY,
  navn VARCHAR(200) NOT NULL,
  pris DECIMAL(10,2) NOT NULL,
  kategori VARCHAR(50) NOT NULL,
  oprettet DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO brugere (navn, email, alder) VALUES
  ('Anders Hansen', '[email protected]', 32),
  ('Marie Nielsen', '[email protected]', 28);
ENDSQL

# Start applikationen
node src/app.js

# Test sikker søgning
curl "http://localhost:3000/api/brugere/søg?q=Anders"
# Output: [{"id":1,"navn":"Anders Hansen","email":"[email protected]"}]

# Test valideringsfejl ved for kort søgeterm
curl "http://localhost:3000/api/brugere/søg?q=A"
# Output: {"fejl":"Valideringsfejl","detaljer":[{"felt":"q","besked":"Søgning skal være mindst 2 tegn"}]}

# Test SQL injection-forsøg - returnerer valideringsfejl, aldrig SQL-fejl
curl "http://localhost:3000/api/brugere/søg?q=' OR '1'='1"
# Output: {"fejl":"Valideringsfejl","detaljer":[{"felt":"q","besked":"Ugyldige tegn i søgning"}]}

Fejlsøgningsguide: 8 typiske problemer

Problem 1: mysql2 kaster "Cannot read properties of undefined (reading 'execute')"
Årsag: Pool ikke oprettet korrekt, eller du importerer mysql i stedet for mysql2.
Løsning: Tjek at du importerer mysql2/promise og at alle miljøvariabler er sat. Kør console.log(pool) for at bekræfte, at pool-objektet eksisterer og er et Pool-objekt.

Problem 2: Prisma kaster "PrismaClientInitializationError"
Årsag: DATABASE_URL miljøvariabel mangler, eller Prisma Client er ikke genereret efter schema-ændring.
Løsning: Kør npx prisma generate efter enhver ændring af schema.prisma. Bekræft at .env indeholder DATABASE_URL=mysql://bruger:password@localhost:3306/dbnavn.

Problem 3: Zod afviser gyldige dansk input med specialtegn
Årsag: Regex-mønstret ekskluderer æ, ø, å eller andre gyldige tegn.
Løsning: Test regex separat med node -e "console.log(/^[a-zA-ZæøåÆØÅ\s\-']+$/.test('Søren Ødegaard'))". Tilføj manglende tegn til mønstret, herunder bindestreg og apostrof til navne.

Problem 4: sqlmap rapporterer ingen injection trods sårbar kode
Årsag: sqlmap tester standardmæssigt på niveau 1. Komplekse injection-punkter kræver højere niveau.
Løsning: Kør med --level=5 --risk=3 for aggressiv testning. Supplement med manuelle tests: send ' OR '1'='1 direkte og tjek serverloggen.

Problem 5: pg returnerer tom array for forespørgsler der burde give resultater
Årsag: PostgreSQL LIKE er case-sensitiv og matcher ikke store/små bogstaver.
Løsning: Brug ILIKE i stedet for LIKE til case-insensitiv søgning i PostgreSQL: WHERE navn ILIKE $1.

Problem 6: Stored procedure kalder fejler med "PROCEDURE does not exist"
Årsag: Applikationsbrugeren har ikke EXECUTE-rettighed til proceduren, eller proceduren er oprettet i en anden database.
Løsning: Kør GRANT EXECUTE ON PROCEDURE node_sikker_db.HentBruger TO 'app_user'@'localhost'; FLUSH PRIVILEGES;

Problem 7: express-validator fejl vises ikke i respons selvom data er ugyldige
Årsag: tjekFejl-middleware er ikke tilføjet til route-kæden efter valideringsreglerne.
Løsning: Husk altid at inkludere tjekFejl mellem regler og handler: router.post('/sti', [...produktRegler], tjekFejl, handleProdukt).

Problem 8: Aikido Firewall blokerer legitime forespørgsler med SQL-lignende indhold
Årsag: Firewall opdager et mønster der ligner injection i lovlig data, f.eks. SQL-kodeeksempler i en blog-database.
Løsning: Konfigurer Aikido til at tillade specifikke endpoints via AIKIDO_BLOCK=false miljøvariabel for det specifikke endpoint, eller brug Aikido-dashboardet til at justere regler.

Relateret dækning

Yderligere Node.js sikkerhedsguides på shattered.io

FAQ: SQL Injection i Node.js

Er parameteriserede forespørgsler nok til at stoppe SQL injection?

Parameteriserede forespørgsler er den primære og mest effektive beskyttelse mod SQL injection. De stopper angreb ved at adskille SQL-kode fra brugerdata på protokolniveau. Kombineret med input-validering og mindste privilegium giver de forsvar med 3 uafhængige lag, og alle tre skal være på plads i en produktionsapplikation.

Kan jeg bruge string escaping i stedet for parameterisering?

Nej. String escaping som mysql.escape() er fejlbehæftet og afhænger af korrekt tegnsætkonfiguration. En forkert konfigureret databaseforbindelse kan omgå escaping. Parameteriserede forespørgsler virker på protokolniveau og er ikke afhængige af tegnsæt. Brug altid parameterisering.

Beskytter Prisma ORM automatisk mod SQL injection?

Ja, Prismas standard query-API parameteriserer automatisk alle værdier. Den eneste undtagelse er prisma.$queryRawUnsafe(), som aldrig må bruges med brugerinput. Brug i stedet prisma.$queryRaw`...` med tagged template literals, der parameteriserer sikkert.

Hvad er forskellen på mysql2 .query() og .execute()?

pool.query() sender forespørgslen og parametre i én pakke. pool.execute() bruger prepared statements på MySQL-protokolniveau: SQL-teksten sendes og kompileres separat, derefter sendes parametre. Prepared statements er det stærkeste forsvar og giver bedre ydeevne ved gentagne forespørgsler. Brug altid execute() med brugerinput.

Kan NoSQL-databaser som MongoDB også rammes af injection?

Ja. MongoDB er sårbar over for NoSQL injection, hvor angribere manipulerer query-objekter med operatorer som $where og $gt. Brug mongoose med schema-validering eller sanitiseringsbiblioteker som mongo-sanitize. Principperne om input-validering og mindste privilegium fra denne guide gælder for alle databasetyper.

Skal jeg køre sqlmap i produktion?

Nej. Kør sqlmap kun mod dine egne udviklingsmiljøer eller dedikerede testmiljøer. sqlmap sender aggressive angrebspayloads der kan overbelaste databaser og generere store mængder falske log-alarmer. I produktion bruger du passive overvågningsværktøjer som Aikido Firewall og centraliseret log-analyse til at opdage angrebsforsøg.

Hvad gør jeg, hvis jeg opdager SQL injection i eksisterende produktionskode?

Behandl det som en sikkerhedshændelse. Tjek omgående logs for tegn på udnyttelse. Implementer en midlertidig WAF-regel eller rate limit på det sårbare endpoint. Ret koden: udskift streng-sammensætning med parameteriserede forespørgsler. Deploy via din normale release-proces. Kør sqlmap mod det rettede endpoint for at verificere. Dokumenter hændelsen.

Virker disse teknikker med TypeScript?

Ja, alle pakker i guiden har fulde TypeScript-definitioner. Prisma er bygget TypeScript-first og genererer typer direkte fra dit schema. Zod er TypeScript-first og giver fuldt type-inferens. mysql2 og pg inkluderer respektive typer. TypeScript hjælper med at forhindre SQL injection, fordi streng type-kontrol gør det sværere at konvertere objekter til strenge i SQL-forespørgsler utilsigtet.

Vil du styrke din Node.js-applikation yderligere? Læs OWASP Top 10 for en oversigt over de mest kritiske webapplikationssårbarheder, og Node.js officielle sikkerhedsguide for platformsspecifikke anbefalinger. Snyk dokumenterer løbende opdateringer om SQL injection-forsvar i Node.js, og Prisma dokumenterer sikker brug af raw queries i detaljer. StackHawk udgav i 2025 en teknisk guide til Node.js SQL injection-eksempler og forebyggelse.