Callback OAuth dapat terlihat valid meskipun berasal dari interaksi browser yang salah. Authorization server mungkin telah menerbitkan code yang nyata, redirect URI mungkin benar, dan pertukaran code mungkin berhasil. Namun jika client tidak dapat menentukan apakah browser ini benar-benar memulai authorization flow tersebut, client dapat mengaitkan identitas eksternal atau authorization yang salah ke session saat ini.
Ini adalah bentuk cross-site request forgery pada callback OAuth. Dalam login flow, salah satu konsekuensinya adalah login CSRF: korban dapat berakhir login ke client sebagai account yang terkait dengan orang lain. Detailnya berbeda antar-aplikasi, tetapi pertanyaan defensifnya tetap sama: apakah callback ini milik transaksi login yang dimulai user agent ini?
Mental model yang berguna adalah memperlakukan setiap upaya OAuth authorization sebagai transaksi pending berumur pendek. Buat transaksi tersebut sebelum browser dialihkan, ikat callback kepadanya, konsumsi satu kali, dan tolak callback yang tidak dapat membuktikan hubungan tersebut.
Authorization response yang valid saja tidak cukup
Pertimbangkan situs yang menawarkan “Sign in with Example Identity.” Authorization-code flow yang disederhanakan terlihat seperti ini:
browser client authorization server
| | |
| start login | |
|------------------>| |
| | authorization request |
| |------------------------>|
| | |
|<---------------- browser redirect ----------|
| | |
| callback with code| |
|------------------>| |
| | exchange code |
| |------------------------>|Authorization code memberi tahu client bahwa authorization server menerbitkan sebuah hasil. Dengan sendirinya, code tersebut belum tentu membuktikan bahwa callback itu milik upaya login yang saat ini direpresentasikan oleh browser session ini.
Perbedaan ini penting karena callback endpoint dapat diakses melalui browser. Jika client menerima callback apa pun yang secara lain valid dan langsung mengaitkan hasilnya dengan local session mana pun yang menerimanya, dua security context berbeda telah tercampur:
- transaksi authorization pada authorization server eksternal;
- local browser session pada client.
Pertahanannya adalah membuat hubungan eksplisit di antara keduanya sebelum browser meninggalkan client.
Modelkan login OAuth sebagai transaksi pending
Saat user memulai login OAuth, client sudah mengetahui context yang berguna. Client tahu bahwa login diminta, authorization server mana yang akan digunakan, dan local browser session mana yang memulai request. Bergantung pada aplikasi, client juga mungkin mengetahui tujuan setelah login atau workflow state non-sensitif lainnya.
Simpan informasi tersebut sebagai transaksi pending dengan lifetime singkat. Secara konseptual:
pending transaction
id: random unpredictable value
browser_session: current session
authorization_server: expected issuer
created_at: current time
return_to: validated local destination
consumed: falseDesain storage persisnya bergantung pada aplikasi. Record server-side yang diberi key berupa nilai acak sering mudah dipahami karena browser hanya membawa opaque identifier sementara context yang relevan bagi security tetap berada di server.
Properti pentingnya bukan bentuk tabel, melainkan invariant berikut:
Callback hanya diterima jika cocok dengan transaksi authorization pending yang dibuat untuk user agent ini dan transaksi tersebut masih valid.
Ini mengubah callback dari event yang tidak diminta menjadi penyelesaian operasi yang sudah diketahui.
Gunakan state untuk membawa binding transaksi
OAuth menyediakan parameter state untuk menghubungkan authorization request dengan callback-nya. Untuk perlindungan CSRF, nilainya perlu sulit ditebak dan terikat pada authenticated state milik user agent. Panduan keamanan OAuth modern juga mengharuskan client melindungi redirect endpoint terhadap CSRF; token CSRF sekali pakai dalam state adalah mekanisme yang mapan ketika mekanisme protokol lain tidak menyediakan perlindungan tersebut.
Desain server-side sederhana bekerja seperti ini:
1. User starts OAuth login.
2. Client generates a cryptographically random transaction ID.
3. Client stores the pending transaction for the current browser session.
4. Client sends the transaction ID as the OAuth state value.
5. Authorization server returns the same state value in the callback.
6. Client looks up the pending transaction.
7. Client verifies that it belongs to the current browser session.
8. Client consumes it before completing the login.Authorization request dapat memuat nilai seperti:
state=<opaque-random-transaction-id>Nilai tersebut bukan password, tetapi security-sensitive selama transaksi masih pending. Attacker yang mengetahui nilai state yang dapat digunakan mungkin melemahkan binding yang seharusnya disediakan nilai tersebut. Hindari memasukkan informasi yang tidak perlu, jangan mencatatnya sembarangan ke log, dan jangan membuatnya dapat diprediksi dari user ID, timestamp, counter, atau data publik lainnya.
Saat callback tiba, bandingkan nilai yang dikembalikan dengan transaksi pending yang diharapkan untuk browser context tersebut. Nilai yang hilang, tidak dikenal, expired, sudah dikonsumsi, atau terikat secara salah harus membuat callback gagal, bukan fallback ke jalur yang kurang ketat.
Ikat ke browser session, bukan hanya database row
Nilai state acak berguna karena pihak lain seharusnya tidak dapat menebak identifier transaksi pending. Namun randomness saja bukan keseluruhan kontrol.
Misalkan aplikasi menyimpan transaksi pending secara global dan menerima setiap transaction ID yang belum expired dari browser mana pun. Jika satu nilai valid bocor, browser berbeda mungkin dapat mengirimkannya. Aplikasi telah membuktikan bahwa transaksi itu ada, tetapi belum membuktikan bahwa user agent saat ini adalah pihak yang memulainya.
Ikat transaksi pending ke local session yang memulai flow. Untuk aplikasi web server-side, ini sering berarti menyimpan internal session identifier atau referensi session lain yang dikendalikan server bersama transaksi. Pada callback, wajibkan kedua hubungan berikut berlaku:
returned state -> pending transaction
current session -> same pending transaction ownerJangan masukkan raw session cookie ke state. Client dapat menyimpan binding di server-side dan hanya mengekspos random transaction identifier. Ini menghindari perubahan mekanisme CSRF menjadi tempat lain tempat session credential dapat bocor.
Sebagian aplikasi memulai login sebelum memiliki local user yang sudah authenticated, tetapi biasanya tetap memiliki temporary browser session atau client-side context yang setara. Binding dilakukan terhadap interaksi user agent, bukan harus terhadap account yang sudah authenticated.
Buat setiap transaksi berumur pendek dan single-use
Transaksi OAuth pending harus mendeskripsikan satu upaya, bukan menjadi capability yang dapat digunakan ulang.
Expiration membatasi berapa lama transaksi yang ditinggalkan tetap dapat diterima. Pilih lifetime yang cukup untuk login interaktif normal sambil mencegah transaksi stale menumpuk tanpa batas. Tidak ada durasi universal untuk semua aplikasi: prompt identity eksternal, multi-factor authentication, jaringan lambat, dan kebutuhan aksesibilitas dapat memengaruhi waktu penyelesaian yang sah.
Penanganan single-use penting karena alasan berbeda. Setelah callback berhasil mengklaim transaksi pending, callback berikutnya dengan transaction identifier yang sama tidak boleh dapat menyelesaikannya lagi.
Pemeriksaan dan konsumsi perlu berperilaku sebagai satu keputusan security. Secara konseptual:
consume transaction
where id = returned_state
and session = current_session
and expires_at > now
and consumed = falseHanya request yang berhasil mengubah transaksi dari pending menjadi consumed yang boleh dilanjutkan. Jika dua callback request tiba hampir bersamaan, ini mencegah keduanya dianggap sebagai penggunaan pertama.
Implementasi production dapat mencapainya dengan database transaction, conditional update, compare-and-set, atau primitive atomic lain yang sesuai dengan storage system. Urutan terpisah “check, lalu nanti tandai used” dapat menciptakan race window.
Pisahkan application state dari security binding
Developer sering ingin OAuth mengembalikan user ke halaman yang sedang dilihat sebelum login. Ada godaan untuk mengodekan tujuan tersebut langsung ke state, misalnya sebagai path atau URL.
Itu memberi dua tugas berbeda pada satu field:
- mengikat callback ke transaksi browser yang memulai;
- membawa application navigation state.
Keduanya dapat berdampingan, tetapi security property menjadi lebih sulit terlihat. Desain server-side yang lebih bersih adalah menjaga nilai OAuth state tetap opaque dan menyimpan informasi navigasi dalam transaksi pending:
state = random transaction ID
server-side transaction:
return_to = /projects/42Validasi return_to sesuai redirect policy aplikasi saat menyimpan atau menggunakannya. Binding transaksi OAuth yang benar tidak boleh tanpa sengaja memperkenalkan open redirect setelah login.
Jika aplikasi sengaja menggunakan nilai state self-contained alih-alih server-side storage, integritas content yang relevan bagi security harus dilindungi dan tetap diikat ke transaksi browser. Sekadar melakukan base64-encoding pada JSON tidak menyediakan integritas; siapa pun yang dapat mengubah nilainya dapat mengubah field hasil decode. Representasi yang signed atau authenticated dapat menangani tampering, tetapi tidak menghapus kebutuhan akan freshness, browser binding yang benar, dan replay handling.
Untuk banyak aplikasi server-rendered, opaque random identifier ditambah server-side state adalah desain yang lebih sederhana untuk diaudit.
Pahami apa yang diubah PKCE
Proof Key for Code Exchange (PKCE) mengikat authorization request ke token request berikutnya menggunakan secret baru yang disebut code verifier. Authorization request membawa code challenge yang diturunkan darinya; token request kemudian menyajikan verifier. Ini membuat authorization code yang dicuri kurang berguna bagi pihak yang tidak memiliki verifier.
Panduan keamanan OAuth saat ini melangkah lebih jauh: ketika client telah memastikan authorization server mendukung PKCE, client dapat mengandalkan transaction binding PKCE untuk perlindungan CSRF. OpenID Connect flow juga dapat menggunakan mekanisme nonce sesuai aturan protokol yang relevan.
Artinya, state bukan satu-satunya kemungkinan pertahanan CSRF dalam deployment OAuth modern. Pelajaran engineering-nya bukan “selalu tambahkan parameter acak lain tanpa memedulikan protokol.” Pelajarannya adalah mengidentifikasi mekanisme mana yang membuktikan bahwa authorization response milik transaksi yang dimulai client ini, lalu memverifikasi mekanisme itu dengan benar.
Jika aplikasi menggunakan state untuk perlindungan CSRF, implementasikan binding yang dijelaskan di artikel ini. Jika aplikasi sengaja mengandalkan PKCE atau mekanisme OpenID Connect, pastikan dukungan authorization server dan asumsi protokol yang diperlukan desain tersebut benar-benar ditegakkan. Jangan diam-diam menghapus binding hanya karena library membuat state opsional.
PKCE juga tidak boleh dianggap sebagai pengganti setiap pemeriksaan OAuth lainnya. Validasi redirect URI, identitas authorization server, validasi token, client authentication jika berlaku, dan application authorization tetap merupakan concern terpisah.
Perhitungkan beberapa authorization server
Client yang mendukung lebih dari satu authorization server memiliki pertanyaan tambahan: dengan server mana transaksi ini dimulai?
Simpan authorization server yang diharapkan dalam transaksi pending. Saat memproses response, terapkan issuer validation atau mix-up defense dari protokol, bukan menganggap semua provider yang dikonfigurasi dapat dipertukarkan.
Secara konseptual:
transaction A -> expected authorization server A
transaction B -> expected authorization server BBrowser session binding menjawab, “Apakah browser ini memulai transaksi ini?” Authorization-server binding menjawab, “Apakah response ini berasal dari server yang diharapkan transaksi ini?” Keduanya terkait tetapi merupakan pemeriksaan berbeda.
Ini adalah contoh bagus mengapa model pending-transaction lebih mudah berkembang daripada satu perbandingan state yang longgar. Transaksi menjadi tempat client mencatat asumsi security yang masih harus benar ketika browser kembali.
Kegagalan implementasi yang umum
Beberapa desain terlihat hampir benar tetapi melemahkan jaminan sebenarnya.
Menerima callback ketika state hilang. Jika state adalah mekanisme CSRF yang dipilih, memperlakukannya sebagai opsional menciptakan bypass. Error handling harus menolak callback, bukan melanjutkan dengan pemeriksaan yang dikurangi.
Menggunakan nilai yang dapat diprediksi. User ID, timestamp saat ini, atau hash dari nilai publik bukan pengganti cryptographically random transaction identifier. Nilai binding harus tahan terhadap tebakan selama masih valid.
Hanya memeriksa bahwa transaksi ada. Keberadaan tidak membuktikan ownership oleh browser session saat ini. Verifikasi session binding juga.
Mengizinkan reuse tanpa batas. Transaksi yang tetap valid setelah berhasil selesai dapat mengubah langkah protokol sekali pakai menjadi replayable. Konsumsi transaksi saat diterima.
Memasukkan secret ke state. Nilai ini berjalan melalui URL yang terlihat browser dan infrastruktur protokol. Gunakan sebagai correlation value, bukan wadah password, access token, session cookie, atau credential lain.
Memercayai return URL yang tidak divalidasi. Perlindungan OAuth CSRF dan redirect safety adalah kontrol terpisah. Batasi navigasi setelah login pada tujuan yang memang dimaksudkan aplikasi.
Menganggap OAuth library menangani semuanya tanpa memeriksa contract. Library berbeda dalam apa yang disimpan, diverifikasi, di-expire, dan dikonsumsi. Konfirmasi perilakunya melalui dokumentasi dan test, bukan menyimpulkan dari login yang berhasil.
Uji security property, bukan hanya happy path
End-to-end login test normal membuktikan bahwa flow bekerja. Itu tidak membuktikan bahwa callback yang tidak terkait ditolak.
Tambahkan test di sekitar invariant pending-transaction. Callback harus gagal ketika binding value hilang, tidak dikenal, expired, sudah dikonsumsi, atau terkait dengan browser session lain. Jika client mendukung beberapa authorization server, uji bahwa transaksi yang dimulai untuk satu server tidak dapat diselesaikan seolah-olah milik server lain.
Uji juga concurrency. Kirim dua upaya penyelesaian untuk transaksi pending yang sama secara berdekatan dan verifikasi hanya satu yang dapat mengklaimnya. Ini menangkap implementasi yang melakukan validasi terlebih dahulu dan baru menandai transaksi used kemudian.
Terakhir, periksa operational log. Log harus dapat memberi tahu bahwa callback OAuth gagal karena transaksinya tidak valid tanpa merekam seluruh security-sensitive callback URL atau reusable credential. Diagnostic yang berguna dan secret minimization dapat berjalan bersama.
Pahami batas kontrol ini
Mengikat callback OAuth ke transaksi yang memulainya mengurangi callback injection bergaya CSRF dan kebingungan cross-session yang tidak disengaja. Ini tidak membuat seluruh deployment OAuth aman dengan sendirinya.
Kontrol ini tidak mengompensasi client yang menerima arbitrary redirect URI, melewati TLS verification, salah menangani identitas authorization server, membocorkan authorization code, menerima token tidak valid, atau memberikan application permission tanpa authorization check. Kontrol ini juga tidak melindungi browser session yang sudah diambil alih attacker; attacker yang mengendalikan session tersebut mungkin dapat memulai transaksi yang terlihat sah dari dalamnya.
Pahami kontrol ini secara sempit: ia membangun continuity antara authorization attempt yang dimulai client dan callback yang sedang diproses client. Continuity tersebut menutup celah penting, tetapi bagian protokol dan aplikasi lainnya tetap memerlukan pemeriksaannya sendiri.
Buat callback menyelesaikan transaksi yang diketahui
Keputusan desain paling berguna adalah berhenti memperlakukan callback OAuth sebagai standalone request. Callback adalah paruh kedua transaksi security-sensitive yang dimulai sebelum browser meninggalkan aplikasi.
Catat transaksi tersebut, ikat ke user agent, gunakan mekanisme CSRF protokol yang sesuai, expire transaksi, konsumsi sekali, dan tolak callback yang tidak cocok. Kemudian uji rejection path dengan kesengajaan yang sama seperti successful login path.
Dengan model ini, penanganan callback OAuth menjadi lebih mudah dipahami: client tidak hanya menanyakan apakah sebuah response terlihat valid. Client menanyakan apakah ini adalah response yang valid untuk authorization attempt pending milik browser ini.