Saya membutuhkan beberapa situs web independen agar berperilaku seperti satu sistem akun tanpa melemahkan isolasi normal browser. Pengguna dapat masuk di satu domain, lalu membuka domain lain secara langsung, mengeklik Log In, dan melanjutkan di sana tanpa memasukkan kredensial lagi.
Kendala pentingnya adalah bahwa situs-situs ini berada di root domain yang independen. Cookie sesi yang dibuat oleh satu root domain tidak bisa begitu saja dicakupkan ke root domain lain yang tidak terkait. Desainnya menjadi jauh lebih mudah dipahami setelah saya memisahkan dua konsep yang sering tercampur: identitas global dan sesi browser lokal.
Model akhirnya sederhana: backend mengenali satu identitas akun bersama, setiap domain memiliki sesi sendiri yang terikat ke host, dan handoff sekali pakai yang berumur pendek memungkinkan origin tepercaya yang sudah mengautentikasi pengguna mengotorisasi pembuatan sesi baru di origin tepercaya lain.
Identitas global tidak memerlukan cookie bersama
Akun dapat bersifat global meskipun sesi browser tetap lokal.
Setiap situs menyimpan cookie sesi khusus host. Cookie yang diterbitkan oleh satu root domain tidak dapat diperluas ke root domain lain yang tidak terkait melalui atribut Domain. Atribut tersebut hanya dapat memperluas cakupan cookie di dalam hierarki domain yang diizinkan untuk digunakan oleh host penerbit.
Untuk sesi yang harus tetap terikat pada satu host, cookie dengan prefiks __Host- cocok digunakan. Secara konseptual:
Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=LaxProperti pentingnya adalah batas-batasnya:
- Site A tidak dapat membaca cookie sesi khusus host milik Site B.
- Site B tidak dapat membaca cookie sesi khusus host milik Site A.
- Kedua sesi tetap dapat dipetakan ke identitas backend yang sama.
Karena itu, SSO lintas domain seharusnya tidak mencoba memindahkan cookie dari satu situs ke situs lain. Yang dipindahkan adalah bukti sementara bahwa sumber tepercaya telah mengautentikasi pengguna.
Pola handoff sekali pakai
Sumber yang sudah terautentikasi menyediakan operasi handoff kecil. Browser memanggilnya hanya ketika perpindahan autentikasi lintas situs memang diperlukan.
Backend membuat tiket acak secara kriptografis, mengembalikan nilai mentahnya ke browser satu kali, lalu hanya menyimpan hash beserta informasi minimum yang diperlukan untuk memvalidasi transfer:
{
tokenHash,
userId,
sourceOrigin,
targetSite,
returnPath,
expiresAt
}Tiket harus berumur pendek dan hanya berlaku untuk satu target. Permintaan penerbitan di sisi sumber harus sudah terautentikasi dan dilindungi dari CSRF. Target yang diminta dan tujuan kembali harus divalidasi sebelum apa pun diterbitkan.
Browser kemudian melakukan POST tingkat atas ke target. Target mengonsumsi tiket, menentukan identitas pengguna bersama, membuat sesi lokalnya sendiri, lalu mengarahkan browser ke path yang dituju.
Secara konseptual:
record = consumeOnce({
tokenHash: hash(ticket),
targetSite: currentSite,
sourceOrigin: requestOrigin,
notExpired: true
})
if (!record) deny()
user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)Tiket bukan sesi target. Tiket adalah otorisasi sekali pakai untuk membuat sesi target tersebut.
Konsumsi atomik menutup celah replay
Implementasi yang lebih lemah akan memuat tiket, memvalidasinya, membuat sesi, lalu menghapus tiket sesudahnya. Ini menciptakan celah waktu ketika dua permintaan dapat melihat record yang sama dan masih valid.
Desain yang lebih aman menggabungkan validasi dan invalidasi dalam satu operasi consume atomik. Jika tidak ada record yang cocok, autentikasi gagal. Jika sebuah record dikembalikan, tiket tersebut sudah dikeluarkan dari kumpulan yang dapat digunakan sebelum sesi dibuat.
Ini juga memungkinkan target mengikat beberapa kondisi ke operasi yang sama: target yang diharapkan, origin sumber yang diharapkan, hash token, dan waktu kedaluwarsa.
Validasi origin harus gagal secara tertutup
Pengikatan origin memberi satu properti berguna lain: tiket yang diterbitkan melalui satu sumber tepercaya seharusnya tidak dapat dikonsumsi dari origin yang tidak terkait.
Pemeriksaan origin sebaiknya menjadi bagian dari kondisi consume itu sendiri. Artinya, permintaan dari origin yang salah tidak mengonsumsi tiket yang valid, sementara sumber yang sah tetap dapat menyelesaikan handoff setelahnya.
Ada satu catatan penting: header Origin tidak dijamin selalu berisi string origin normal dalam setiap konteks browser. Sebagian permintaan dapat tiba tanpa nilai yang dapat digunakan, dan konteks opaque dapat menghasilkan Origin: null.
Aturan saya adalah memperlakukan Origin yang hilang atau opaque sebagai kegagalan pemeriksaan origin, kecuali aplikasi memiliki mekanisme validasi alternatif yang eksplisit. Nilai literal null tidak boleh dianggap sebagai origin tepercaya hanya demi kompatibilitas.
Ini adalah pilihan fail-closed. Jika alur yang sah perlu mendukung konteks tanpa origin yang dapat digunakan, pengecualian itu harus dirancang secara eksplisit, bukan dengan diam-diam melemahkan pemeriksaan umum.
Jauhkan kredensial handoff dari URL
Saya lebih memilih mengirim tiket sementara melalui POST formulir tingkat atas daripada menaruhnya di query string.
Versi umum di sisi klien adalah:
const form = document.createElement('form');
form.method = 'POST';
form.action = targetOrigin + '/auth/consume';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'ticket';
input.value = ticket;
form.append(input);
document.body.append(form);
form.submit();Target menerima tiket dari body POST yang diharapkan dan menolak upaya setara untuk memberikannya melalui URL.
Ini tidak membuat kredensial menjadi tidak berbahaya, tetapi menjauhkannya dari URL navigasi biasa dan mengurangi paparan tidak sengaja melalui riwayat URL, tautan yang disalin, referrer, dan pencatatan log yang berorientasi URL.
Tautan lintas situs yang terautentikasi dapat melakukan upgrade sendiri
Jika situs saat ini sudah mengetahui bahwa pengguna telah terautentikasi, tautan biasa ke situs tepercaya lain dapat secara oportunistis melakukan handoff.
Tautan tersebut tetap harus memiliki href yang nyata. SSO adalah peningkatan terhadap navigasi normal, bukan penggantinya.
Alur klien yang disederhanakan terlihat seperti ini:
if (session.status !== 'authenticated') {
return; // ordinary browser navigation
}
if (!isTrustedTarget(destination)) {
return;
}
event.preventDefault();
const ticket = await requestHandoff({
targetSite,
returnPath: destinationPath
});
postTicketToTarget(ticket);Versi produksi juga harus mencegah pengiriman ganda, memvalidasi target terhadap registry tepercaya, dan kembali ke navigasi normal jika handoff tidak dapat dibuat.
Perilaku bawaan browser sebaiknya tetap dipertahankan. Klik tombol tengah, klik dengan tombol modifier, unduhan, dan tautan yang ditujukan untuk konteks penelusuran lain tidak boleh diam-diam diubah menjadi permintaan autentikasi.
Tombol Log In di situs kedua memerlukan issuer yang diketahui
Kasus yang lebih sulit dimulai ketika pengguna membuka Site B secara langsung. Site B tidak memiliki sesi lokal, sehingga dengan benar memperlakukan pengunjung sebagai anonim meskipun browser masih memiliki sesi yang valid di Site A.
Site B tidak dapat memeriksa cookie khusus host milik Site A. Site B juga tidak dapat mengarahkan ke Site C sembarang lalu berharap situs tersebut menemukan sesi milik bagian sistem lainnya. Setiap root domain yang tidak terkait hanya dapat melihat cookie miliknya sendiri.
Artinya, alur Log In anonim memerlukan issuer yang diketahui: baik origin autentikasi kanonik maupun situs tepercaya tertentu yang dipilih dan diharapkan menyimpan sesi login yang dapat digunakan kembali.
Untuk kasus sederhana Site A → Site B, urutannya adalah:
- Pengguna masuk di Site A.
- Kemudian pengguna membuka Site B secara langsung.
- Site B tidak memiliki sesi, sehingga menampilkan Log In.
- Pengguna mengeklik Log In.
- Browser melakukan navigasi tingkat atas ke Site A, yang merupakan issuer terpilih.
- Site A dapat membaca cookie sesi khusus host miliknya sendiri yang masih ada.
- Karena sesi tersebut masih valid, Site A langsung menerbitkan handoff sekali pakai untuk Site B.
- Browser melakukan POST tiket ke Site B.
- Site B membuat sesi lokalnya sendiri dan mengembalikan pengguna ke halaman yang diminta.
Dari sudut pandang pengguna, ini hanyalah redirect singkat ke Site A lalu kembali. Kata sandi tidak diperlukan karena Site A sudah memiliki sesi yang memang boleh diamatinya.
Batasannya juga sama penting: jika pengguna hanya login di Site C, mengarahkan Site B ke Site A tidak membantu kecuali Site A benar-benar merupakan broker autentikasi pusat dengan pengetahuan independen tentang status login. Basis data pengguna bersama tidak membuat sesi browser menjadi bersama.
Mengapa issuer kanonik menyederhanakan sistem
Ketika jumlah situs independen bertambah, issuer kanonik menghilangkan kebutuhan untuk menemukan sesi di berbagai domain sembarang.
Tanpa issuer kanonik, Site B yang anonim harus entah bagaimana menentukan situs lain mana yang saat ini mungkin memiliki cookie yang berguna. Site A tidak dapat memeriksa cookie Site C, Site C tidak dapat memeriksa cookie Site A, dan Site B tidak dapat memeriksa keduanya.
Issuer kanonik memberi setiap upaya login anonim satu jalur yang dapat diprediksi:
Site B
→ issuer autentikasi
→ sesi issuer tersedia?
ya → buat handoff sekali pakai
tidak → tampilkan login normal
→ Site B mengonsumsi handoff
→ Site B membuat sesi lokalIssuer tidak perlu melayani bagian aplikasi lainnya. Tanggung jawab yang relevan adalah memiliki sesi autentikasi yang dapat digunakan kembali dan menerbitkan handoff dengan cakupan ketat ke situs tepercaya.
Untuk sistem yang lebih kecil, situs yang sudah ada dapat menjalankan peran tersebut. Untuk arsitektur yang lebih luas, origin autentikasi khusus dapat membuat model kepercayaan lebih mudah dipahami.
Jangan mengalihkan setiap pengunjung melalui issuer
Alur issuer tidak seharusnya berjalan otomatis untuk setiap tampilan halaman anonim.
Mengarahkan setiap pengunjung ke domain lain hanya untuk memeriksa apakah ada sesi yang masih berlaku menciptakan navigasi yang tidak perlu, latensi tambahan, lebih banyak mode kegagalan, analitik yang lebih bising, dan perilaku caching yang lebih rumit.
Ini juga tidak cocok untuk halaman publik yang sensitif terhadap SEO. Crawler atau pengunjung biasa yang meminta URL publik seharusnya langsung menerima halaman publik tersebut, bukan dipaksa melewati jalur autentikasi terlebih dahulu.
Aturan yang saya gunakan adalah:
Jangan memeriksa issuer saat halaman dimuat. Mulai autentikasi lintas domain hanya setelah tindakan Log In yang eksplisit atau navigasi lintas situs terautentikasi yang memang disengaja.
Dengan begitu, trafik publik tetap sederhana sementara autentikasi tetap cepat bagi pengguna yang sudah memiliki sesi valid pada issuer yang dipilih.
Validasi return path membutuhkan perhatian lebih daripada sekadar pemeriksaan prefiks
Handoff biasanya perlu mengingat di mana browser harus mendarat setelahnya. Nilai itu dapat menjadi open redirect jika diperlakukan sebagai URL sembarang yang dikendalikan klien.
Jika memungkinkan, desain teraman adalah tidak menerima URL tujuan lengkap sama sekali. Identifier pendek atau pemetaan sisi server untuk tujuan yang sudah diketahui mengurangi jumlah parsing dan validasi URL yang diperlukan.
Jika relative path harus diterima, validasi dengan cermat dan pastikan tetap terbatas pada situs target.
Contoh yang sengaja disederhanakan—bukan rutin validasi redirect yang lengkap—adalah:
function isSafeReturnPath(value) {
if (!value.startsWith('/')) return false;
if (value.startsWith('//')) return false;
if (value.includes('\\\\')) return false;
const base = 'https://example.invalid';
const parsed = new URL(value, base);
return parsed.origin === base;
}Implementasi nyata juga perlu mempertimbangkan perilaku decoding, input yang tidak valid, karakter kontrol, normalisasi, aturan routing aplikasi, serta transformasi oleh proxy atau framework yang terjadi sebelum redirect dihasilkan.
Invarian keamanannya lebih sederhana daripada detail parser: handoff boleh memilih lokasi yang diizinkan di aplikasi target, dan tidak pernah tujuan eksternal sembarang.
Kasus kegagalan lebih penting daripada jalur sukses
Login yang berhasil hanya membuktikan bahwa jalur dasar bekerja. Pengujian yang lebih berguna justru menguji batas kepercayaan.
Saya memverifikasi perilaku untuk kasus-kasus seperti:
- sumber yang belum terautentikasi mencoba menerbitkan handoff;
- perlindungan CSRF tidak ada;
- origin hilang, opaque, atau tidak sesuai harapan;
- target yang tidak tepercaya;
- tujuan kembali yang tidak aman;
- tiket diberikan ke target yang salah;
- upaya consume dari origin yang salah;
- replay tiket;
- tiket kedaluwarsa;
- pengguna yang sudah tidak ada;
- tiket diberikan melalui URL, bukan melalui body POST yang diharapkan.
Dua invarian sangat berguna untuk diuji secara langsung: permintaan yang ditolak tidak boleh secara tidak sengaja menghancurkan tiket yang masih valid untuk alur yang sah, dan consume yang berhasil harus segera membuat tiket tersebut tidak dapat digunakan lagi.
Protokol dapat diskalakan tanpa integrasi berpasangan
Desain handoff pada dasarnya tidak berubah ketika lebih banyak situs ditambahkan.
Setiap situs yang berpartisipasi membutuhkan kontrak kecil yang sama:
- identifier logis yang stabil;
- origin publik yang tepercaya;
- akses ke identitas akun bersama;
- operasi penerbitan handoff untuk sumber yang terautentikasi;
- operasi consume handoff;
- registry situs tepercaya;
- sesi lokal yang terikat ke host;
- kebijakan tujuan kembali yang aman;
- dan issuer yang diketahui untuk alur Log In anonim.
Pilihan penting untuk skalabilitas adalah menghindari logika autentikasi khusus yang berpasangan antarsitus. Situs yang sudah terautentikasi dapat menerbitkan jenis handoff yang sama ke situs tepercaya lain. Situs anonim dapat mengirim browser ke issuer yang diketahui.
Dengan demikian, sistem tetap didasarkan pada satu protokol, bukan matriks kasus khusus yang terus membesar.
Portabilitas login terpisah dari propagasi logout
Sesi independen yang terikat ke host menghasilkan satu pembedaan berguna lagi: membuat login dapat dibawa antarsitus tidak secara otomatis menentukan logout global.
Jika pengguna logout dari Site A, sesi lokal di situs lain dapat tetap valid kecuali backend sengaja mencabutnya. Itu adalah kebijakan manajemen sesi, bukan kegagalan pada handoff SSO.
Sistem dapat memilih antara logout lokal, logout global, atau manajemen sesi eksplisit. Memisahkan keputusan tersebut dari handoff membuat kedua mekanisme lebih mudah dipahami.
Model yang saya gunakan
Arsitekturnya menjadi lebih sederhana setelah saya berhenti mendeskripsikan masalah ini sebagai “berbagi cookie login lintas domain”.
Ada satu identitas global, setiap domain memiliki sesi browsernya sendiri, dan handoff sekali pakai yang berumur pendek memungkinkan origin tepercaya yang sudah terautentikasi mengotorisasi pembuatan sesi lokal lain.
Untuk akses langsung ke situs anonim, ada satu aturan tambahan yang penting: browser harus dikirim ke issuer yang benar-benar dapat melihat sesi pengguna yang sudah ada. Jika pengguna sebelumnya login pada issuer tersebut, perjalanan pergi-pulangnya bisa hampir tidak terasa. Jika issuer tidak memiliki sesi valid, issuer harus kembali ke autentikasi normal, bukan berpura-pura bahwa sesi milik domain lain yang tidak terkait dapat terlihat.
Model ini mempertahankan batas domain browser, menghindari redirect autentikasi yang tidak perlu bagi pengunjung publik, dan tetap memberikan pengalaman yang dimaksud: login di issuer, buka situs tepercaya lain di kemudian hari, klik Log In, lalu kembali dengan sesi lokal baru tanpa memasukkan kata sandi lagi.