Kort sagt: Säkra tokens skapar ni i PHP med random_bytes(), till exempel 32 byte som ni omvandlar till text med bin2hex() eller Base64-URL. För slumptal inom ett intervall finns random_int(), för koder ur en fast teckenuppsättning sedan PHP 8.3 Random\Randomizer::getBytesFromString(). Tokens lagrar ni som hash, begränsar deras giltighet, använder dem bara en gång och jämför dem med hash_equals().
En länk som är för lätt att gissa
”Glömt lösenordet?” Ett klick, och systemet skickar en länk med en lång kod. Den som känner till länken kan sätta lösenordet. Just därför måste koden vara oförutsägbar. Är den inte det behöver en angripare varken postlådan eller det gamla lösenordet, bara tillräckligt många försök.
Vid granskningar av PHP-applikationer hittar man förvånansvärt ofta tokens som är byggda av klockslaget, användar-ID:t eller en enkel slumptalsgenerator. De ser långa och slumpmässiga ut men är det inte. Detsamma gäller API-nycklar, inbjudningslänkar, delningslänkar för dokument, bekräftelsekoder och CSRF-tokens.
Risken är högst konkret: En förutsägbar återställningslänk gör det möjligt att ta över varje konto vars e-postadress är känd, utan att innehavaren märker något förrän han eller hon inte längre själv kan logga in.
PHP erbjuder sedan version 7.0 en enkel och säker lösning. Den behöver bara användas konsekvent.
random_bytes i PHP: varifrån säkra slumpvärden kommer
random_bytes() levererar enligt PHP-manualen kryptografiskt säkra slumpbyte som även lämpar sig för långlivade hemligheter som nycklar. Funktionen använder operativsystemets slumpkälla, under Linux till exempel getrandom(), under Windows CNG-gränssnittet. Hittar den ingen lämplig källa kastar den sedan PHP 8.2 ett Random\RandomException.
Översikten visar vilka funktioner som duger för hemligheter:
| Funktion | Lämplig för tokens? | Anmärkning |
|---|---|---|
random_bytes() |
ja | säkra byte, sedan PHP 7.0 |
random_int() |
ja | säkert tal inom ett intervall, sedan PHP 7.0 |
Random\Randomizer med Secure-motor |
ja | Secure är enligt manualen standardmotorn |
rand(), mt_rand() |
nej | förutsägbara, bara för icke säkerhetsrelevanta syften |
uniqid() |
nej | tidsbaserad, avsedd för unikhet |
md5(time()), sha1($id) |
nej | indata går att gissa, hashen ändrar inte på det |
str_shuffle(), array_rand() |
nej | använder ingen säker generator |
Skapa tokens: längd och kodning
32 slumpmässiga byte motsvarar 256 bitar och är för nästan alla ändamål mer än tillräckligt. För överföring i länkar eller headers måste tokenet omvandlas till text. Två varianter är vanliga:
<?php
declare(strict_types=1);
// Hex: 64 tecken, bara 0-9 och a-f
$tokenHex = bin2hex(random_bytes(32));
// Base64-URL: 43 tecken, säkert i URL:er utan ytterligare kodning
$tokenUrl = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
// Slumptal inom ett intervall, till exempel för ett urval
$index = random_int(0, 9);
// Från PHP 8.3: sexsiffrig kod, t.ex. för en bekräftelse via SMS
$randomizer = new \Random\Randomizer();
$code = $randomizer->getBytesFromString('0123456789', 6);
Korta sifferkoder är försvarbara bara om antalet försök är strängt begränsat och koden förfaller efter några minuter. För länkar som skickas via e-post tar ni alltid det långa tokenet.
För API-nycklar som partner eller egna system använder varaktigt har två tillägg visat sig fungera. Ett kort, icke hemligt prefix före slumpdelen, till exempel för miljö eller ändamål, underlättar identifiering i loggar och vid fynd i kodförråd. Och nyckeln visas bara en gång när den skapas; därefter känner systemet bara dess hash. Går en nyckel förlorad ersätts den, den återskapas inte.
Var försiktiga med egenbyggda tokens som innehåller information, till exempel användar-ID och utgångstid i Base64. Kodning är ingen säkring: Vem som helst kan läsa och ändra sådana tokens. Ska ett token bära data måste det vara signerat med en hemlig nyckel, till exempel med hash_hmac('sha256', ...), och signaturen kontrolleras med hash_equals(). För mer komplicerade fall är granskade bibliotek att föredra framför egna konstruktioner.
Lagra, kontrollera och låta tokens förfalla
Ett säkert skapat token är bara halva vinsten. Minst lika viktigt är hur applikationen hanterar det. Ett beprövat förfarande för återställnings- och inbjudningslänkar:
- Lagra bara hashen: I databasen står
hash('sha256', $token), tokenet självt går bara via länken till användaren. - Begränsa giltigheten: Lagra utgångstiden och kontrollera den vid inlösen.
- Använd en gång: Efter lyckad användning raderar ni posten eller markerar den som förbrukad.
- Ogiltigförklara vid ändringar: Efter en lösenordsändring raderar ni alla öppna återställningstokens för kontot.
- Jämför i konstant tid: Där ett token jämförs direkt med ett lagrat värde använder ni
hash_equals(), med det lagrade värdet som första parameter.
<?php
// Skapa
$token = bin2hex(random_bytes(32));
$eintrag = [
'konto_id' => $kontoId,
'token_hash' => hash('sha256', $token),
'gueltig_bis'=> (new DateTimeImmutable('+30 minutes'))->format('Y-m-d H:i:s'),
];
// skicka $token bara i länken, lagra $eintrag
// Lösa in
$gesucht = hash('sha256', (string) ($_GET['token'] ?? ''));
// Sök posten via token_hash med prepared statement,
// kontrollera utgångstid, radera därefter posten direkt
Begränsa dessutom antalet återställningsförfrågningar per konto och tidsrymd så att funktionen inte kan missbrukas för massutskick av e-post. Svaret på en återställningsförfrågan bör dessutom inte avslöja om den angivna adressen över huvud taget har ett konto.
En snabb hash som SHA-256 räcker här, eftersom tokenet självt redan är långt och slumpmässigt. För lösenord som människor väljer gäller inte det; där behövs långsamma metoder, som Lagra lösenord säkert i PHP förklarar.
Tokens hör inte hemma i loggfiler. Kontrollera om er webbserver loggar fullständiga URL:er med frågeparametrar och om felrapporter skickar med förfrågningsdata.
Efter en incident: förnya tokens och nycklar
Den som kunde köra kod på en server hade i regel också åtkomst till lagrade tokens, API-nycklar och sessionsdata. Finns efter PHP2Shell eller en annan incident misstanke om ett intrång bör ni därför förnya inte bara lösenord utan också långlivade tokens och nycklar: API-åtkomster, signeringsnycklar för cookies eller länkar, åtkomster till betal- och fraktleverantörer.
Sessioner är ett specialfall: PHP skapar sessions-ID:n själv. Hur ni säkrar deras inställningar och avslutar sessioner efter en incident beskriver guiden Säkra sessioner i PHP. För formulärtokens mot förfalskade förfrågningar, se CSRF-skydd i PHP-applikationer. Ytterligare inbyggda verktyg presenterar PHP:s inbyggda säkerhetsfunktioner.
Hur hjälper PHP SOS konkret?
- Vi hittar i er applikation alla ställen där tokens, koder och nycklar skapas och ersätter osäkra källor.
- Vi kontrollerar lagring, utgång och engångsanvändning av återställnings-, inbjudnings- och delningslänkar.
- Vi ser till att tokens inte hamnar i loggar, felrapporter eller analysverktyg.
- Efter en incident planerar vi tillsammans med er det ordnade utbytet av tokens och nycklar.
Granskning av egen PHP-programvara erbjuder vi som säkerhetsaudit och penetrationstest.
Vanliga frågor
Varför är mt_rand olämplig för tokens?
mt_rand och rand är avsedda för statistik och spel, inte för hemligheter. Deras utdata går under vissa omständigheter att förutsäga. För allt som en angripare inte får gissa använder ni random_bytes eller random_int.
Hur långt bör ett token vara?
För länkar för att återställa lösenord, inbjudningar eller API-nycklar är 32 slumpmässiga byte ett solitt val, som hextext 64 tecken. 16 byte, alltså 128 bitar, är en vanlig nedre gräns.
Är uniqid ett säkert token?
Nej. uniqid bygger på den aktuella tiden och är avsedd för unikhet, inte för sekretess. Även kombinationer som md5 av tid eller användar-ID är förutsägbara.
Måste jag kryptera återställningstokens i databasen?
Lagra i stället för tokenet en hash av det, till exempel med SHA-256. Den som läser databasen kan då inte bygga giltiga länkar. Eftersom tokenet självt redan är långt och slumpmässigt behövs ingen långsam metod som för lösenord.
Vad händer om systemet inte hittar någon slumpkälla?
random_bytes och random_int kastar sedan PHP 8.2 ett Random\RandomException. Fånga det fallet och avbryt förloppet i stället för att falla tillbaka på en svagare källa.