امنترین روش ارتباط بین دو سرویس بکاند چیه؟ - ارتباط امن بین API ها
حدود ۱۱ دقیقه مطالعه
یه روز یکی از سرویسهای داخلیت باید با یه سرویس دیگه حرف بزنه. مثلاً سرویس سفارش باید به سرویس پرداخت بگه «این سفارش رو تسویه کن». اولین راهی که به ذهن خیلیها میرسه اینه: یه endpoint باز کن، یه API key هم بذار توش، تمام. مشکل از همینجا شروع میشه، چون «داشتن یه کلید» با «امن بودن ارتباط» فاصلهی زیادی داره.
قبل از هر چیز بذار یه چیزی رو روشن کنم: انتخاب پروتکل (REST، GraphQL، gRPC) اصلاً چیزی نیست که امنیت رو تضمین کنه. REST یه انتخاب کاملاً خوبه، ولی خود REST بودن هیچ ربطی به امن بودن نداره. امنیت از جای دیگهای میاد: از اینکه چطور احراز هویت میکنی، چطور تصمیم میگیری کی به چی دسترسی داره، چطور داده رو رمزنگاری میکنی، چطور ورودیها رو validate میکنی، و چقدر دو سیستم رو از هم ایزوله نگه میداری. این پنج تا با هم امنیت رو میسازن، نه انتخاب فرمت پیام.
معماری کلی، قبل از رفتن سراغ جزئیات
یه تصویر کلی از یه معماری معقول این شکلیه: سرویس A از طریق HTTPS به یه API Gateway یا reverse proxy میرسه، اونجا احراز هویت، تعیین دسترسی، rate limiting و محدودیت IP اعمال میشه، بعد درخواست به سرویس B واقعی میرسه، و در نهایت به دیتابیس. نکتهی مهم اینه که هیچکدوم از این لایهها به تنهایی کافی نیستن — اینها روی هم دفاع در عمق (defense in depth) رو میسازن. هدف طراحی یه سیستم «غیرقابل نفوذ» نیست (چنین چیزی وجود نداره)، هدف اینه که اگه یه لایه شکست خورد، بقیهی سیستم خودکار قربانی نشه.
HTTPS اجباریه، نه اختیاری
این نکته آنقدر بدیهی به نظر میرسه که خیلیها ازش رد میشن، ولی هنوز هم میبینم سرویسهای داخلی که فقط چون «پشت فایروال هستن» با HTTP ساده کار میکنن. فرقی نمیکنه ترافیک بین دو تا دیتاسنتر رد بشه یا بین دو تا container توی یه کلاستر — بدون TLS، هر کسی که بین راه بایسته میتونه credential ها و دادهها رو بخونه. این یعنی همیشه https://api.example.com، هیچوقت http://api.example.com، حتی برای ترافیک کاملاً داخلی.
برای ارتباط سرویس به سرویس، میتونی از این هم جلوتر بری و از mutual TLS (mTLS) استفاده کنی. توی TLS معمولی فقط کلاینت مطمئن میشه داره با سرور درست حرف میزنه؛ توی mTLS هر دو طرف certificate دارن و همدیگه رو verify میکنن. راهاندازیش توی Node.js با ماژول https این شکلیه:
const https = require('https');
const fs = require('fs');
const server = https.createServer({
key: fs.readFileSync('server-key.pem'),
cert: fs.readFileSync('server-cert.pem'),
ca: fs.readFileSync('client-ca.pem'),
requestCert: true,
rejectUnauthorized: true,
}, (req, res) => {
res.end('اتصال معتبره');
});
server.listen(443);
نکتهی requestCert: true و rejectUnauthorized: true اینجاست که اگه کلاینت certificate معتبر نداشته باشه، اصلاً اجازهی وصل شدن پیدا نمیکنه — قبل از اینکه حتی یه خط از کد اپلیکیشنت اجرا بشه. برای API های داخلی حساس، mTLS یکی از قویترین گزینههاست. مشکلش اینه که مدیریت certificate ها (تمدید، revoke کردن، rotate کردن) خودش یه پروژهی جداست؛ اگه چند تا سرویس داری و مدیریتش برات سخته، ابزارهایی مثل service mesh (Istio، Linkerd) این کار رو خودکار میکنن.
از JWT کاربر برای ارتباط سرویس به سرویس استفاده نکن
یه اشتباه رایج اینه که همون JWT ای که برای احراز هویت کاربر نهایی صادر شده رو برای ارتباط بین دو تا سرویس backend هم دوبارهکاری میکنن — یعنی چیزی شبیه Authorization: Bearer <user-jwt> رو بین سرویسها پاس میدن. این کار مرز بین «هویت کاربر» و «هویت سرویس» رو از بین میبره و باعث میشه permission های کاربر و سرویس قاطی بشن. به جاش باید یه credential مخصوص machine-to-machine داشته باشی. گزینههای خوب اینجا شامل OAuth 2.0 Client Credentials، mTLS، درخواستهای امضاشده (signed requests)، ترکیب API key و HMAC، و توکنهای کوتاهمدت سرویسمحور هستن.
برای بیشتر زیرساختهای متوسط، ترکیب OAuth 2.0 Client Credentials بههمراه HTTPS انتخاب معقولیه؛ برای ارتباطات داخلی که واقعاً حساس هستن، mTLS بههمراه توکنهای کوتاهمدت گزینهی قویتریه.
هر سرویس هویت خودش رو داشته باشه
یه اشتباه دیگه که خیلی رایجه: یه کلید API واحد و master برای همهچیز. چیزی شبیه یه متغیر محیطی به اسم MASTER_API_KEY که همهی سرویسها ازش استفاده میکنن. مشکل این روش اینه که وقتی یکی از سرویسها compromise بشه، عملاً کل سیستم compromise شده — چون همه از یه کلید استفاده میکردن.
راه درستتر اینه که هر سرویس یه service account مخصوص خودش داشته باشه، و به هر کدوم فقط همون permission هایی که واقعاً بهش نیاز داره داده بشه. مثلاً اگه سرویس A فقط باید کاربر بسازه و بخونه، نباید اصلاً امکان حذف کاربر یا دسترسی به بخش پرداخت رو داشته باشه:
سرویس A → مجاز به: create_user, read_user, update_access
سرویس A → غیرمجاز به: delete_user, access_payments, access_admin, access_database
به این اصل، least privilege میگن: هر جزء از سیستم فقط دسترسیای رو داشته باشه که برای انجام کارش لازمه، نه بیشتر. این سادهترین و در عین حال مؤثرترین اصلی هست که خیلی از تیمها فراموشش میکنن.
احراز هویت با تعیین دسترسی فرق داره
احراز هویت (authentication) به این سؤال جواب میده: «تو کی هستی؟» و تعیین دسترسی (authorization) به این سؤال: «تو اجازهی چه کاری رو داری؟» یه فرض اشتباه رایج اینه که فکر کنیم چون یه request احراز هویت شده، پس قابل اعتماده. این درست نیست. باید هر endpoint جداگانه چک بشه که آیا سرویسی که این درخواست رو فرستاده، مجوز انجام همین عملیات خاص رو داره یا نه. مثلاً POST /users ممکنه نیاز به مجوز user:create داشته باشه، در حالی که DELETE /users/:id ممکنه به یه مجوز کاملاً متفاوت نیاز داشته باشه که خیلی محدودتر بهش داده میشه.
امضای درخواستها با HMAC
برای APIهایی که سطح امنیتی بالاتری میخوان، امضای درخواست (request signing) با HMAC یه لایهی اضافهی خیلی خوبیه. ایده اینه که هر دو سرویس یه secret مشترک دارن، و هر request قبل از ارسال با اون secret امضا میشه:
const crypto = require('crypto');
function signRequest(method, path, body, secret) {
const timestamp = Date.now().toString();
const payload = method + path + timestamp + JSON.stringify(body);
const signature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return { timestamp, signature };
}
این امضا معمولاً توی هدرهایی مثل این ارسال میشه:
X-Client-ID: service-a
X-Timestamp: 1760000000
X-Signature: a3f9c2...
سمت سرور، همین امضا دوباره از روی method، path، timestamp و body محاسبه میشه و با چیزی که تو header اومده مقایسه میشه. اگه یکی وسط راه request رو بگیره و مثلاً ایمیل داخل body رو عوض کنه، امضا دیگه match نمیکنه و request رد میشه — بدون اینکه اصلاً لازم باشه محتوای request رمزنگاری بشه؛ همین که تغییرناپذیریش تضمین بشه کافیه.
محافظت در برابر replay attack
فقط امضا کردن request کافی نیست. اگه کسی یه request معتبر و امضاشده رو ضبط کنه و بعداً دوباره همونو بفرسته، امضا هنوز معتبره چون چیزی توش عوض نشده. این همون replay attack هست. راهحلش ترکیب سه چیزه: timestamp، یه شناسهی یکتا برای هر request (نانس یا request-id)، و امضا:
X-Timestamp: 1760000000
X-Request-ID: 550e8400-e29b-41d4-a716-446655440000
X-Signature: a3f9c2...
منطق سمت سرور این شکلیه: اول چک میشه timestamp توی یه بازهی معقول هست (مثلاً کمتر از پنج دقیقه اختلاف با زمان فعلی سرور)، بعد چک میشه که این request-id قبلاً پردازش نشده (این بخش معمولاً با یه cache سریع مثل Redis پیاده میشه، با یه TTL برابر با همون بازهی زمانی)، و اگه هر دو شرط برقرار بود، امضا verify میشه و در نهایت request-id ذخیره میشه تا دفعهی بعد تکراری تشخیص داده بشه. بدون این مکانیزم، حتی امضای HMAC هم در برابر یه حملهی سادهی «ضبط و پخش دوباره» بیدفاعه.
شبکه رو محدود کن، این شاید مهمترین قدمه
اگه سرویس B فقط قراره از طرف سرویس A صدا زده بشه، هیچ دلیلی نداره که اصلاً از اینترنت عمومی در دسترس باشه. این یکی از نکاتی هست که تأثیرش روی امنیت کلی سیستم از خیلی چیزهای دیگه بیشتره، چون هر لایهی امنیتی دیگهای که بذاری (authentication، rate limit، signature) باز هم در معرض حمله قرار میگیره اگه شبکه بهطور عمومی باز باشه.
ابزارهایی که برای این کار به کار میان شامل قوانین فایروال، security group های ابری، شبکهی خصوصی (VPC)، VPN یا WireGuard، IP allowlist، و DNS داخلی هستن. ایدهآل اینه که ترافیک بین دو سرویس فقط از یه شبکهی خصوصی رد بشه، نه از مسیر عمومی اینترنت که بعد پشت فایروال فیلتر بشه. این دو تا از نظر امنیتی خیلی با هم فرق دارن، حتی اگه نتیجهی نهایی مشابه به نظر برسه.
هیچوقت دسترسی مستقیم دیتابیس نده
یه اشتباه خطرناک که گاهی توی معماریهای عجلهای دیده میشه اینه که یه سرویس بیرونی، مستقیماً credential دیتابیس سرویس دیگه رو داشته باشه. حتی اگه این credential محدود به یه کاربر readonly باشه، این کار لایهی authorization اپلیکیشن رو کاملاً دور میزنه. مسیر درست همیشه از طریق API میگذره: سرویس A فقط باید از طریق HTTPS و API عمومی سرویس B باهاش حرف بزنه، و سرویس B خودش تصمیم میگیره که این عملیات با توجه به authorization چطور روی دیتابیس اجرا بشه. سرویس A هیچوقت نباید بتونه کاری بیشتر از اونی که API صراحتاً exposed کرده انجام بده.
اعتماد کورکورانه به ورودی، حتی از سرویس داخلی
حتی اگه یه request از یه سرویس authenticated بیاد، بازم باید ورودیهاش validate بشن — schema، طول رشتهها، فرمت شناسهها، URL ها، اسم فایلها، enum ها، بازهی عددی، و حجم payload. یه نکتهی خاص اینجا مربوط به SSRF هست: اگه سرور تو بر اساس یه URL که از سمت کاربر یا سرویس دیگه اومده (مثلاً یه فیلد callback_url) یه request جدید میسازه، بدون validate کردن اون آدرس ممکنه یه آسیبپذیری SSRF ایجاد بشه که مهاجم بتونه از طریق سرورت به منابع داخلی شبکه دسترسی پیدا کنه که اصلاً نباید بهشون دسترسی داشته باشه.
rate limit فقط برای ترافیک بیرونی نیست
یه سرویس authenticated لزوماً امن نیست. اگه سرویس A به هر دلیلی — یه باگ، یه حلقهی بینهایت، یه retry logic خراب — شروع کنه به بمباران کردن سرویس B، معتبر بودن token یا امضا هیچ کمکی نمیکنه. گذاشتن rate limit حتی روی ترافیک کاملاً داخلی، چیزیه که خیلی وقتها دیر بهش فکر میکنیم؛ معمولاً بعد از اینکه یه بار یه سرویس داخلی کل کلاستر رو از پا درآورده. منطقیترین کار اینه که برای هر endpoint بر اساس هزینهی محاسباتیش یه سقف جدا تعریف کنی؛ یه request خواندنی ساده و یه عملیات export سنگین طبیعتاً نباید سقف یکسانی داشته باشن.
لاگ کردن، اما نه هر چیزی
وقتی یه چیزی خراب میشه، اولین جایی که سراغش میری لاگهاست. صرفاً لاگ کردن مسیر و متد request (POST /users) کافی نیست؛ چیزهایی که واقعاً کمککننده هستن شامل timestamp، اسم سرویس فرستنده، endpoint، شناسهی یکتای request، نتیجهی عملیات، تصمیم authorization، آدرس IP مبدأ، و latency میشن. نکتهی خیلی مهم اینه که هیچوقت secret ها یا credential های حساس رو توی لاگ ننویسی — این خودش یه منبع رایج نشت اطلاعاته که کمتر بهش توجه میشه.
مقایسهی روشهای ارتباطی
انتخاب پروتکل بین دو سرویس بیشتر به الگوی استفاده بستگی داره تا امنیت، هرچند بعضی گزینهها ذاتاً برای یه سناریوی خاص مناسبترن:
| روش | مناسب برای |
|---|---|
| REST روی HTTPS | APIهای عمومی پلتفرم، انعطافپذیر و پرکاربرد |
| GraphQL روی HTTPS | کوئریهای منعطف سمت کلاینت |
| gRPC با mTLS | ارتباط سرویسبهسرویس داخلی، latency پایین |
| WebSocket | ارتباط real-time دوطرفه |
| صف پیام (Message Queue) | عملیات async و پسزمینهای |
| اتصال مستقیم به دیتابیس | باید کاملاً پرهیز بشه |
| HTTP بدون TLS | هیچوقت |
برای ارتباط بین دو پلتفرم مستقل، REST بههمراه HTTPS و OAuth2 (یا mTLS برای موارد حساستر) یه انتخاب کاملاً محکمه. برای میکروسرویسهای داخلی که تعداد زیادی درخواست کوچیک و پرتکرار با هم رد و بدل میکنن، gRPC یا یه message broker معمولاً منطقیتره.
نکتهای که در آخر ارزش تکرار کردن داره: هدف طراحی یه API که «هک کردنش غیرممکنه» نیست — چنین چیزی وجود خارجی نداره. هدف اینه که سیستم رو طوری بچینی که compromise شدن یه جزء، خودکار به compromise شدن همهچیز منجر نشه. ایزوله کردن شبکه، least privilege، credential های جداگانه برای هر سرویس، محدود کردن دسترسی دیتابیس، و دفاع در عمق، در عمل خیلی بیشتر از انتخاب REST یا GraphQL یا هر پروتکل دیگهای روی امنیت واقعی سیستم تأثیر میذارن.
