เก็บ secret ใน .env แบบเข้ารหัส แล้วถอดอัตโนมัติตอน config โหลด
ไฟล์ .env คือจุดที่รหัสทุกตัวของระบบมากองรวมกันเป็น plaintext บรรทัดต่อบรรทัด ใครหลุดเข้าไปอ่านได้ครั้งเดียวก็ได้ไปทั้งชุด ผมเลยเปลี่ยนให้ค่าสำคัญเก็บเป็น ciphertext แล้วให้แอปถอดเองตอน config โหลด โดยไม่ต้องแก้โค้ดฝั่งที่เรียกใช้เลยสักบรรทัด
พอดีมีงานที่ต้องเอา Laravel ขึ้น shared hosting แล้วในไฟล์ .env ไฟล์เดียวมีทั้งรหัส admin ของแผงควบคุมโฮสต์ รหัส database สามชุด และ API key ของ registrar ที่กดสั่งซื้อโดเมนได้จริง คิดดูว่าถ้าวันหนึ่งมี directory listing หลุด หรือ backup ไปวางผิดที่ คนที่ได้ไฟล์นี้ไปคือได้กุญแจบ้านทั้งพวง ผมเลยทำระบบเล็กๆ ให้ค่าพวกนี้เก็บเป็น ciphertext แล้วถอดตอนโหลด config โดยที่โค้ดส่วนที่เรียกใช้ไม่ต้องรู้เรื่องอะไรด้วยเลย
ตรงนี้ต้องพูดให้ชัดก่อนว่ามันไม่ใช่ระบบกันเทพ — คนที่เข้าถึงเครื่องได้ระดับรัน php artisan tinker ได้ ก็อ่านค่าจริงได้อยู่ดี เพราะกุญแจ (APP_KEY) อยู่ในไฟล์เดียวกันนั่นแหละ สิ่งที่ได้จริงคือยกระดับจาก "เห็นแล้วก๊อปไปใช้ได้ทันที" เป็น "ต้องมีสิทธิ์รันโค้ดบนเครื่องก่อนถึงจะได้ค่า" ซึ่งกันเคสที่เจอบ่อยที่สุดได้เกือบหมด: .env หลุดไปกับ backup, โดนอ่านผ่านช่องโหว่ path traversal, เผลอ commit ขึ้น repo, หรือเปิดหน้าจอให้คนอื่นดูตอนแก้ไฟล์
ค่าทั้งหมดในบทความนี้เป็นค่าสมมุติที่ผมสร้างขึ้นมาใหม่ ไม่ใช่ของระบบไหนจริง แต่โครงสร้างและโค้ดคือของที่ใช้งานอยู่
หน้าตาของ .env หลังเปลี่ยน
APP_KEY=base64:Q0hBTkdFTUUtRkFLRS1LRVktRk9SLURFTU8tT05MWQ==
# ค่าที่เข้ารหัสแล้ว — ยาวขึ้นเยอะ แต่แอปอ่านได้ปกติ
DA_API_KEY=eyJpdiI6IjRuS3BXb1UzczlRPT0iLCJ2YWx1ZSI6IjdqU2t2WGh2...
DB_PASSWORD=eyJpdiI6ImhZUmFGMEs1Zzg9PSIsInZhbHVlIjoiT2daNHFMbXQ...
REGISTRAR_API_KEY=eyJpdiI6IlZ0ODJkR3BLZz09IiwidmFsdWUiOiJVdzNu...
# ค่าที่ไม่ต้องเข้ารหัส — ไม่ใช่ความลับ เป็นโครงสร้าง
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USERNAME=app_user
DA_READ_ONLY=true
จุดที่ผมชอบของวิธีนี้คือมันผสมกันได้ — ค่าไหนยังไม่ได้เข้ารหัสก็ยังใช้งานได้ตามปกติ ไม่ต้องแปลงทีเดียวทั้งไฟล์แล้วมานั่งลุ้นว่าจะพังตรงไหน ทยอยทำทีละตัวได้สบายๆ
helper ตัวเดียว: ตรวจเองว่าเข้ารหัสไหม แล้วถอดให้
หัวใจอยู่ที่คลาสสั้นๆ ตัวเดียว หน้าที่มันคือรับสตริงจาก env() เข้ามา ถ้าดูแล้วเป็น ciphertext ก็ถอดคืนเป็นค่าจริง ถ้าเป็นค่าธรรมดาก็ส่งกลับไปเฉยๆ
<?php
namespace App\Support;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
final class Secret
{
/** คืนค่าที่ถอดรหัสแล้ว — ค่าธรรมดาปล่อยผ่านตามเดิม */
public static function get(?string $value): ?string
{
if ($value === null || $value === '') {
return $value;
}
// Laravel Crypt = base64(JSON{iv,value,mac,tag}) -> ขึ้นต้นด้วย "eyJ" และยาวเกิน 80 เสมอ
if (! Str::startsWith($value, 'eyJ') || strlen($value) < 80) {
return $value;
}
try {
$key = self::key();
if ($key === '') {
return $value; // ไม่มีกุญแจ ก็คืนค่าดิบไปก่อน
}
return (new Encrypter($key, 'AES-256-CBC'))->decryptString($value);
} catch (\Throwable $e) {
return $value; // ถอดไม่ออก = ไม่ใช่ ciphertext จริง
}
}
/** เข้ารหัส plaintext ด้วย APP_KEY — ใช้จาก artisan command */
public static function encrypt(string $plaintext): string
{
$key = self::key();
if ($key === '') {
throw new \RuntimeException('APP_KEY ว่าง — สั่ง php artisan key:generate ก่อน');
}
return (new Encrypter($key, 'AES-256-CBC'))->encryptString($plaintext);
}
private static function key(): string
{
$key = (string) env('APP_KEY', '');
return Str::startsWith($key, 'base64:') ? base64_decode(substr($key, 7)) : $key;
}
}
การตรวจใช้ heuristic สองชั้น: ขึ้นต้นด้วย eyJ (เพราะ payload ของ Laravel เป็น base64 ของ JSON ที่ขึ้นต้นด้วย {"iv" เสมอ) และยาวอย่างน้อย 80 ตัวอักษร รหัสผ่านจริงที่มนุษย์ตั้งแทบไม่มีทางเข้าเงื่อนไขทั้งสองพร้อมกัน และต่อให้บังเอิญเข้าเงื่อนไข ตัว try/catch ก็คืนค่าดิบให้อยู่ดี — ผมออกแบบให้ทุกทางที่ผิดพลาดคืนค่าเดิม ไม่ใช่โยน exception เพราะ exception ตอน config โหลดหมายถึงเว็บขาวทั้งเว็บ ไม่ใช่แค่ฟีเจอร์เดียวเสีย
ฝั่งที่เรียกใช้คือไฟล์ config ปกติ ห่อ env() ไว้ชั้นเดียว แค่นั้น:
// config/services.php
'directadmin' => [
'url' => env('DA_URL'),
'user' => env('DA_USER'),
// ถ้าใน .env เป็น ciphertext จะถอดให้ ถ้าเป็นค่าธรรมดาก็ปล่อยผ่าน
'key' => Secret::get(env('DA_API_KEY')),
],
// config/database.php
'mysql' => [
'host' => env('DB_HOST', '127.0.0.1'),
'username' => env('DB_USERNAME'),
'password' => \App\Support\Secret::get(env('DB_PASSWORD', '')),
],
โค้ดแอปที่เหลือไม่ต้องแก้อะไรเลย เรียก config('services.directadmin.key') แล้วได้ค่าจริงเหมือนเดิมทุกประการ
ทำไมต้องเลี่ยง Crypt facade
จุดนี้คือจุดที่คนทำแล้วติดกันเยอะ และเป็นเหตุผลที่ผมต้อง new Encrypter เองแทนที่จะเรียก Crypt::decryptString() ให้สั้นๆ จบ
ลำดับการบูตของ Laravel คือโหลดไฟล์ config ทั้งโฟลเดอร์ก่อน แล้วค่อย register service provider ตัว facade Crypt ผูกอยู่กับ EncryptionServiceProvider ซึ่งยัง register ไม่เสร็จตอนที่ไฟล์ใน config/ กำลังถูก include อยู่ เรียกไปตอนนั้นก็จะได้ RuntimeException: A facade root has not been set — และที่แย่กว่านั้นคือมันพังตั้งแต่ bootstrap เว็บทั้งเว็บขาวทันที ไม่ใช่พังเฉพาะหน้าที่ใช้ค่านั้น
ใช้ในไฟล์ config ไม่ได้
facade ต้องรอ service container ผูก instance ให้ก่อน ซึ่งเกิดหลังจาก config โหลดจบแล้ว เรียกตอนนั้น = facade root has not been set = เว็บขาวทั้งเว็บ
สร้างเองไม่พึ่ง container
เป็นแค่คลาส PHP ธรรมดา รับกุญแจกับ cipher แล้วทำงานได้ทันที ใช้ได้ทุกช่วงเวลาของการบูต รวมถึงในไฟล์ config
หลักการเดียวกันนี้ใช้ได้กับทุกอย่างที่ต้องทำงานในไฟล์ config — อะไรที่อยู่ในไฟล์ config ห้ามพึ่ง facade หรือ service container ให้ใช้ของที่ new เองได้เท่านั้น ไม่งั้นเจอปัญหาไก่กับไข่แน่นอน
ถ้ารัน php artisan config:cache ผลลัพธ์ที่ถูกเขียนลง bootstrap/cache/config.php คือค่าที่ถอดรหัสแล้ว = plaintext ล้วนๆ ทั้งไฟล์ นั่นแปลว่าไฟล์ cache ตัวนี้ต้องคุมสิทธิ์ให้แน่นเท่ากับ .env (600 เจ้าของเป็น user ของเว็บ) และห้ามหลุดเข้า backup ที่แชร์ให้คนอื่น ผมกันด้วยการใส่ทั้ง .env และ bootstrap/cache/ ไว้ในลิสต์ที่ต้องเช็คสิทธิ์ทุกครั้งหลัง deploy
artisan command ที่ต้องทดสอบถอดกลับก่อนพิมพ์ผล
ตัวเข้ารหัสผมทำเป็น artisan command เพราะต้องรันบนเครื่องปลายทาง (เดี๋ยวจะอธิบายว่าทำไมในหัวข้อถัดไป) จุดที่ผมคิดว่าสำคัญที่สุดของคำสั่งนี้ไม่ใช่ตอนเข้ารหัส แต่เป็นการถอดกลับมาเทียบก่อนจะยอมให้เอาค่าไปใช้
<?php
namespace App\Console\Commands;
use App\Support\Secret;
use Illuminate\Console\Command;
class EncryptSecret extends Command
{
protected $signature = 'secret:encrypt {plaintext : ค่าที่จะเข้ารหัส (ใส่ quote ถ้ามีช่องว่างหรืออักขระพิเศษ)}';
protected $description = 'เข้ารหัส secret ด้วย APP_KEY สำหรับเก็บลง .env';
public function handle(): int
{
$plain = (string) $this->argument('plaintext');
if ($plain === '') {
$this->error('plaintext ต้องไม่ว่าง');
return self::FAILURE;
}
$cipher = Secret::encrypt($plain);
$this->line($cipher);
// ถอดกลับเทียบทันที — ถ้าไม่ตรงห้ามเอาไปใช้เด็ดขาด
if (Secret::get($cipher) !== $plain) {
$this->error('round-trip decrypt ไม่ผ่าน — ห้ามเอาค่านี้ไปใช้');
return self::FAILURE;
}
$this->comment('# round-trip OK · length ' . strlen($cipher));
return self::SUCCESS;
}
}
เวลาใช้จริงหน้าตาแบบนี้:
php artisan secret:encrypt 'Xq7mPd2vRt'
eyJpdiI6IjRuS3BXb1UzczlRPT0iLCJ2YWx1ZSI6IjdqU2t2WGh2TzBhcnFuSUE9PSIsIm1hYyI6ImE0...
# round-trip OK · length 188
ทำไมต้องเสียเวลาถอดกลับ ทั้งที่เพิ่งเข้ารหัสเองหมาดๆ — เพราะเคสที่ผมกลัวคือ APP_KEY ในไฟล์มีปัญหาแบบเงียบๆ เช่นก๊อปมาแล้วมีช่องว่างท้ายบรรทัด หรือ base64 ขาดตัวอักษรไปตัวหนึ่ง กรณีแบบนั้น encryptString() ยังทำงานได้ตามปกติ ได้ ciphertext ออกมาสวยงาม แต่เอาไปวางใน .env แล้วถอดไม่ออก แล้วเราจะรู้ตัวอีกทีตอนแอปพยายามต่อ DB ไม่ติดกลางดึก การเช็คบรรทัดเดียวตรงนี้ทำให้ค่าที่พิมพ์ออกมาทุกตัวผ่านการพิสูจน์แล้วว่าถอดกลับได้บนเครื่องเครื่องนี้จริง
อะไรควรเข้ารหัส อะไรไม่ต้อง
ผมไม่เข้ารหัสทุกบรรทัดครับ เพราะยิ่งเข้ารหัสเยอะ ยิ่ง debug ยาก และค่าที่ไม่ใช่ความลับก็ไม่ได้ประโยชน์อะไรจากการซ่อน เกณฑ์ที่ผมใช้ง่ายมาก: ค่านี้หลุดไปแล้วมีคนเอาไปทำอะไรเสียหายได้ทันทีหรือเปล่า ถ้าใช่ เข้ารหัส ถ้าไม่ ปล่อยไว้อย่างนั้น
| ตัวแปร | เข้ารหัส | เหตุผล |
|---|---|---|
DA_API_KEY |
ใช่ | คุมโฮสติ้งได้ทั้งเครื่อง สร้าง/ลบ user ได้ |
DB_PASSWORD |
ใช่ | เขียนฐานข้อมูลได้เต็มสิทธิ์ |
REGISTRAR_API_KEY |
ใช่ | ย้าย/ต่ออายุโดเมนได้ เสียเงินจริง |
MAIL_PASSWORD |
ใช่ | ส่งเมลในนามโดเมนเราได้ |
PAYMENT_SECRET |
ใช่ | ยืนยันธุรกรรมได้ |
APP_KEY |
ไม่ | มันคือกุญแจเอง เข้ารหัสตัวเองไม่ได้ |
DB_HOST / DB_PORT / DB_USERNAME |
ไม่ | โครงสร้าง ไม่ใช่ความลับ |
APP_URL / path / locale |
ไม่ | เปิดเผยอยู่แล้ว |
flag แบบ DA_READ_ONLY=true |
ไม่ | เข้ารหัสแล้วอ่านยาก debug ยาก ไม่ได้อะไรเพิ่ม |
| client ID / public key ต่างๆ | ไม่ | เป็นตัวระบุ ไม่ใช่ตัวยืนยันตัวตน |
ส่วนเครื่อง dev ในบ้านผมเก็บเป็น plaintext ตามเดิมครับ ความเสี่ยงต่ำ แล้วเวลาไล่บั๊กจะได้ไม่ต้องมาถอดรหัสดูทีละตัว — ตัว pass-through ใน Secret::get() ทำให้ทั้งสองแบบใช้โค้ดชุดเดียวกันได้โดยไม่ต้องแยก branch อะไรเลย
กับดักใหญ่: APP_KEY ต่างกันต่อ environment
หัวข้อนี้คือหัวใจของบทความ ถ้าจะอ่านหัวข้อเดียวให้อ่านอันนี้
กุญแจที่ใช้เข้ารหัสคือ APP_KEY ในไฟล์ .env ของแต่ละเครื่อง ซึ่งเครื่อง dev กับ production มีคนละค่ากันเสมอ (และควรจะเป็นแบบนั้น) ผลที่ตามมาตรงไปตรงมามาก: ค่าที่เข้ารหัสบนเครื่อง dev เอาไปวางบน production แล้วถอดไม่ออก แล้วด้วยดีไซน์ของ helper ที่ผมทำให้ "ถอดไม่ออกให้คืนค่าดิบ" มันจะไม่ error ด้วยนะครับ — แอปจะเอา ciphertext ยาวๆ นั่นแหละไปใช้เป็นรหัสผ่าน แล้วได้ผลเป็น "login failed" ที่ดูไม่ออกเลยว่าต้นเหตุคืออะไร
ถ้ามีใครเผลอรัน php artisan key:generate บน production (บ่อยมากตอน deploy ครั้งแรกหรือตอนก๊อป .env.example มาใช้) APP_KEY จะถูกเขียนทับด้วยค่าใหม่ แล้วค่าที่เข้ารหัสไว้ทุกตัวจะถอดไม่ออกพร้อมกันทั้งหมด — DB ต่อไม่ติด API เรียกไม่ผ่าน เมลไม่ออก พร้อมกันในวินาทีเดียว และย้อนกลับไม่ได้เพราะกุญแจเก่าหายไปแล้ว ทางแก้มีทางเดียวคือเอา plaintext ต้นฉบับมาเข้ารหัสใหม่ด้วยกุญแจใหม่ ซึ่งแปลว่าต้องมีที่เก็บ plaintext สำรองไว้เสมอ นอกเครื่อง (password manager หรือ vault) ไม่ใช่เก็บไว้แค่ใน .env ตัวเดียวในโลก
ขั้นตอนที่ผมใช้ตอนเอาขึ้น production เป็นแบบนี้ครับ ยาวหน่อยแต่ทำครั้งเดียวจบ:
- สำรอง .env ของเครื่องปลายทางไว้ก่อนแตะอะไรทั้งสิ้น — ทั้งไฟล์ ไม่ใช่แค่บรรทัดที่จะแก้
- เช็คว่า APP_KEY มีค่าอยู่แล้วและไม่ว่าง ถ้าว่างค่อยรัน key:generate ตอนนี้ครั้งเดียว หลังจากนี้ห้ามรันอีก
- SSH เข้าเครื่องปลายทาง แล้วรัน php artisan secret:encrypt ที่นั่น ทีละค่า เก็บผลที่ขึ้นต้น eyJ ไว้
- วางค่าที่ได้ลง .env ของเครื่องนั้น แล้วเคลียร์ config cache ด้วย php artisan config:clear
- ยืนยันว่าถอดได้จริงก่อนปิดงาน อย่าเดาว่าน่าจะได้
- เก็บ plaintext ต้นฉบับไว้ใน password manager แล้วลบไฟล์ชั่วคราวที่ใช้ระหว่างทางให้หมด รวมถึงเคลียร์ shell history ถ้าเผลอพิมพ์รหัสลงไปตรงๆ
ขั้นที่ 5 ยืนยันด้วยคำสั่งนี้:
php artisan tinker --execute='echo config("services.directadmin.key"), PHP_EOL;'
# ต้องได้ค่าจริง ไม่ใช่ eyJ...
ถ้าผลที่ออกมายังขึ้นต้นด้วย eyJ แปลว่าถอดไม่ผ่าน อย่าเพิ่งไปต่อ ให้ย้อนกลับไปดูว่ารันคำสั่งเข้ารหัสบนเครื่องถูกเครื่องหรือเปล่า
คำสั่ง php artisan secret:encrypt 'รหัสจริง' จะไปโผล่ใน ~/.bash_history ของเครื่องปลายทาง ซึ่งเสียของหมด เพราะเราอุตส่าห์เข้ารหัสใน .env แล้วดันทิ้ง plaintext ไว้ใน history เฉยเลย ผมใส่ช่องว่างนำหน้าคำสั่ง (ถ้าตั้ง HISTCONTROL=ignorespace ไว้) หรือไม่ก็ลบบรรทัดนั้นออกจาก history แล้ว history -c ทันทีหลังทำงานเสร็จ ตรวจให้เห็นกับตาว่าไม่เหลือ
สรุปสิ่งที่ได้กับสิ่งที่ไม่ได้
สิ่งที่ได้คือ .env ที่หลุดไปแล้วเอาไปใช้ต่อทันทีไม่ได้ ต้องมี APP_KEY คู่กันด้วย ซึ่งในหลายเคสของการรั่วจริง (backup หลุด, ไฟล์โดนอ่านผ่านช่องโหว่, เผลอ commit) คนที่ได้ไปมักได้ไปแค่บางส่วน — และการเปลี่ยนจาก "รหัสอยู่ตรงหน้า ก๊อปได้เลย" เป็น "ต้องหาคีย์มาถอดก่อน" ก็ซื้อเวลาให้เราไป rotate รหัสได้ทัน ส่วนสิ่งที่ไม่ได้คือมันกัน attacker ที่รันโค้ดบนเครื่องได้แล้วไม่ได้ ถ้าถึงจุดนั้นเกมจบไปนานแล้วครับ อย่าไปหลอกตัวเองว่าปลอดภัยขึ้นในมิตินั้น
ส่วนตัวผมมองว่าราคาของวิธีนี้ถูกมากเมื่อเทียบกับสิ่งที่ได้ — โค้ดจริงคือคลาสเดียวกับ command เดียว ห่อ env() ในไฟล์ config เพิ่มไม่กี่บรรทัด แล้วส่วนที่เหลือของแอปไม่รู้เรื่องอะไรด้วยเลย ใครจะเอาไปปรับใช้ก็ตามสะดวกครับ จะเปลี่ยน heuristic เป็นคำนำหน้าของตัวเอง เช่นเติม enc: นำหน้าแล้วตัดออกก่อนถอด ก็ชัดเจนกว่าเดิมอีกและไม่ต้องเดาจาก base64 เลย ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย