Use Cases (Diperbarui: 2/6/2026)

Implementasi OAuth dengan Claude Code: PKCE, state, nonce, dan token aman

Panduan OAuth 2.1/2.0 Authorization Code + PKCE dengan Claude Code, lengkap dengan state, nonce, redirect, dan token.

Implementasi OAuth dengan Claude Code: PKCE, state, nonce, dan token aman

OAuth belum selesai hanya karena tombol “Login dengan Google” sudah muncul. Kesalahan kecil bisa menautkan akun yang salah, memakai ulang authorization code, mengganti redirect URI, atau membocorkan token ke JavaScript browser. Panduan ini menjelaskan cara memakai Claude Code untuk membuat dasar OAuth tanpa memberi secret asli, lalu meninjau hasilnya secara ketat.

Default praktisnya adalah OAuth 2.0 Authorization Code + PKCE, sejalan dengan OAuth 2.1. PKCE membuat aplikasi membuktikan di token endpoint bahwa ia masih memiliki verifier asli yang terkait dengan challenge saat login dimulai. Untuk konteks lain, baca Claude Code authentication, Claude Code API development, dan getting started.

Bentuk implementasi

Gunakan Claude Code untuk routes, session, tests, dan checklist review. Jangan tempel client secret asli, refresh token, .env produksi, atau screenshot console provider. Berikan nama variabel, perilaku yang diharapkan, dan kasus gagal.

AreaRekomendasiAlasan
FlowAuthorization Code + PKCEMenghindari paparan token dari flow lama
CSRFSimpan dan verifikasi stateMencegah callback palsu
OIDC replaySimpan dan verifikasi nonceMendeteksi hasil identitas yang diputar ulang
Redirect URIExact allow-list matchMencegah penggantian tujuan
TokenSession server atau store terenkripsiMenjauhkan token panjang dari localStorage
Input Claude CodeNilai dummy dan spesifikasiMencegah secret masuk prompt dan log

Rujukan utama: OAuth 2.1, RFC 7636 PKCE, RFC 9700 OAuth 2.0 Security BCP, OpenID Connect Core, OWASP OAuth2 Cheat Sheet, Claude Code Security.

Tiga use case nyata

Pertama, dashboard admin B2B SaaS. User login lewat Google Workspace atau Microsoft Entra ID, tetapi aplikasi tetap memetakan ke user, organisasi, dan role internal. OAuth membuktikan identitas; layer authorization menentukan izin.

Kedua, akses API terdelegasi. Kalender, email, storage, dan CRM butuh consent user, scope kecil, refresh token terenkripsi, dan alur disconnect/revoke.

Ketiga, tool internal dan layanan bergaya MCP. API key statis mudah di awal, tetapi sulit untuk revoke dan audit. OAuth/OIDC memberi batas login, consent, expiry, dan identity yang lebih jelas.

Mobile app dan SPA tidak bisa menyembunyikan client secret, jadi PKCE wajib. Web app server-side pun sebaiknya memakai PKCE agar review lebih mudah.

Demo lokal siap copy-paste

Demo ini tidak membutuhkan kredensial Google, Microsoft, atau Auth0. Satu proses Express menjadi OAuth client dan mock authorization server, sehingga state, nonce, PKCE S256, validasi redirect exact, authorization code sekali pakai, dan token di session server bisa dilihat langsung.

Buat file berikut di folder kosong, jalankan npm install && npm start, lalu buka http://localhost:3000.

{
  "scripts": { "start": "node server.mjs" },
  "dependencies": { "express": "^4.19.2", "express-session": "^1.18.0" },
  "engines": { "node": ">=20" }
}
// server.mjs
import crypto from "node:crypto";
import express from "express";
import session from "express-session";

const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(session({
  name: "oauth_demo_sid",
  secret: "dev-only-change-this-32-byte-secret",
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, sameSite: "lax", secure: false, maxAge: 10 * 60 * 1000 },
}));

const client = {
  clientId: "claude-code-demo",
  redirectUri: "http://localhost:3000/callback",
  scope: "openid profile email",
};
const authorizationEndpoint = "http://localhost:3000/mock/authorize";
const tokenEndpoint = "http://localhost:3000/mock/token";
const registeredRedirectUris = new Set([client.redirectUri]);
const pendingCodes = new Map();

function randomUrlSafe(bytes = 32) {
  return crypto.randomBytes(bytes).toString("base64url");
}
function sha256Base64Url(value) {
  return crypto.createHash("sha256").update(value).digest("base64url");
}
function fail(res, status, message) {
  return res.status(status).type("text/plain").send(message);
}

app.get("/", (_req, res) => {
  res.type("html").send(`<h1>OAuth PKCE local demo</h1><p><a href="/auth/login">Start login</a></p>`);
});

app.get("/auth/login", (req, res) => {
  const state = randomUrlSafe();
  const nonce = randomUrlSafe();
  const codeVerifier = randomUrlSafe(48);
  const codeChallenge = sha256Base64Url(codeVerifier);
  req.session.oauth = { state, nonce, codeVerifier, createdAt: Date.now() };

  const params = new URLSearchParams({
    response_type: "code",
    client_id: client.clientId,
    redirect_uri: client.redirectUri,
    scope: client.scope,
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  });
  res.redirect(`${authorizationEndpoint}?${params}`);
});

app.get("/mock/authorize", (req, res) => {
  const p = req.query;
  const redirectUri = String(p.redirect_uri || "");
  if (p.response_type !== "code") return fail(res, 400, "response_type must be code");
  if (p.client_id !== client.clientId) return fail(res, 400, "unknown client_id");
  if (!registeredRedirectUris.has(redirectUri)) return fail(res, 400, "redirect_uri is not registered exactly");
  if (p.code_challenge_method !== "S256") return fail(res, 400, "PKCE S256 is required");
  if (!p.code_challenge || !p.state || !p.nonce) return fail(res, 400, "missing state, nonce, or PKCE challenge");

  const code = randomUrlSafe(24);
  pendingCodes.set(code, {
    clientId: client.clientId,
    redirectUri,
    codeChallenge: String(p.code_challenge),
    nonce: String(p.nonce),
    expiresAt: Date.now() + 60_000,
    used: false,
  });

  const redirect = new URL(redirectUri);
  redirect.searchParams.set("code", code);
  redirect.searchParams.set("state", String(p.state));
  res.redirect(redirect.toString());
});

app.get("/callback", async (req, res) => {
  const oauth = req.session.oauth;
  const code = String(req.query.code || "");
  const returnedState = String(req.query.state || "");
  if (!oauth) return fail(res, 400, "missing OAuth session");
  if (returnedState !== oauth.state) return fail(res, 403, "state mismatch: possible CSRF or mixed login attempt");

  const response = await fetch(tokenEndpoint, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: client.redirectUri,
      client_id: client.clientId,
      code_verifier: oauth.codeVerifier,
    }),
  });
  const tokens = await response.json();
  if (!response.ok) return fail(res, response.status, JSON.stringify(tokens, null, 2));
  if (tokens.nonce !== oauth.nonce) return fail(res, 403, "nonce mismatch: possible replay");

  req.session.oauth = undefined;
  req.session.tokenSet = {
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token,
    expiresAt: Date.now() + tokens.expires_in * 1000,
  };
  res.redirect("/dashboard");
});

app.post("/mock/token", (req, res) => {
  const body = req.body;
  const record = pendingCodes.get(body.code);
  if (body.grant_type !== "authorization_code") return res.status(400).json({ error: "unsupported_grant_type" });
  if (!record || record.used || record.expiresAt < Date.now()) return res.status(400).json({ error: "invalid_grant" });
  if (body.client_id !== record.clientId) return res.status(400).json({ error: "invalid_client" });
  if (body.redirect_uri !== record.redirectUri) return res.status(400).json({ error: "invalid_redirect_uri" });
  if (sha256Base64Url(body.code_verifier || "") !== record.codeChallenge) return res.status(400).json({ error: "invalid_code_verifier" });

  record.used = true;
  res.json({ token_type: "Bearer", access_token: randomUrlSafe(32), refresh_token: randomUrlSafe(32), expires_in: 300, nonce: record.nonce });
});

app.get("/dashboard", (req, res) => {
  const tokenSet = req.session.tokenSet;
  if (!tokenSet) return res.redirect("/auth/login");
  const secondsLeft = Math.max(0, Math.floor((tokenSet.expiresAt - Date.now()) / 1000));
  res.type("html").send(`<h1>Logged in</h1><p>Access token is stored server-side, not in localStorage.</p><p>Expires in ${secondsLeft} seconds.</p>`);
});

app.listen(3000, () => console.log("Open http://localhost:3000"));

Perhatikan: /auth/login menyimpan state, nonce, dan code_verifier di session server; request hanya mengirim code_challenge; /callback memverifikasi state; /mock/token menghitung ulang S256; /dashboard membaca token dari server, bukan localStorage.

Prompt untuk Claude Code

Implementasikan OAuth 2.0 Authorization Code + PKCE di Express + TypeScript.
Syarat:
- Definisikan konfigurasi provider hanya sebagai nama environment variable; jangan tulis secret asli.
- Simpan state, nonce, dan code_verifier di session server.
- Validasi redirect_uri dengan exact match terhadap nilai konfigurasi.
- Izinkan hanya code_challenge_method=S256.
- Jangan simpan access token atau refresh token di localStorage.
- Simpan refresh token hanya di field database terenkripsi atau session server.
- Tambahkan test untuk success, state mismatch, PKCE mismatch, expired code, dan code reuse.
- Akhiri dengan checklist review keamanan.

Untuk review:

Review implementasi OAuth ini terhadap RFC 9700, RFC 7636, dan OWASP OAuth2 Cheat Sheet.
Cek apakah secret muncul di log, snapshot test, frontend bundle, atau Git diff.
Klasifikasikan temuan sebagai High/Medium/Low dan usulkan patch.

Jebakan konkret

Jangan validasi redirect URI dengan prefix. https://app.example.com.evil.test/callback bukan aplikasi Anda. Gunakan exact match dengan URI terdaftar.

Jangan hanya membuat state tanpa mengeceknya. Simpan saat login dimulai, bandingkan di callback, lalu hapus. Untuk banyak tab, simpan transaksi pendek dengan state sebagai key.

Jangan izinkan PKCE plain tanpa alasan kuat. Pakai S256, jangan log verifier, buat code cepat kedaluwarsa dan sekali pakai.

Untuk OIDC, nonce saja tidak cukup. Validasi signature JWT, issuer, audience, expiry, dan nonce. ID Token adalah bukti identitas; Access Token adalah otorisasi API.

Hindari localStorage untuk token panjang. Web app biasa lebih cocok memakai session server, tabel token terenkripsi, cookie pendek, dan revoke eksplisit. Detail cookie ada di Claude Code cookie management.

Checklist kualitas komersial

  • Umur token dan refresh behavior terdokumentasi.
  • Tim dapat menjelaskan state, nonce, dan PKCE dengan bahasa sendiri.
  • Redirect URI, scope, dan setting provider dicatat.
  • Log tidak berisi code, verifier, token, atau cookie.
  • Error tidak membuka detail internal.
  • Test mencakup success, mismatch, expiry, dan reuse.
  • Output Claude Code direview memakai sumber resmi.

Training dan konsultasi ClaudeCodeLab bisa mengubah proses ini menjadi workshop tim: bawa OAuth flow yang ada, petakan risiko, tambah test, dan pulang dengan checklist review yang bisa dipakai ulang. Lihat ClaudeCodeLab training.

Verifikasi langsung

Demo ini menjalankan awal login, mock authorization, callback, token exchange, dan dashboard. Jika state diubah, aplikasi berhenti dengan state mismatch. Jika verifier salah, token endpoint mengembalikan invalid_code_verifier. Dalam prototipe OAuth, masalah yang sering lolos bukan happy path, tetapi banyak tab, tombol back, code kedaluwarsa, dan log tidak sengaja. Masukkan kasus ini ke prompt Claude Code pertama.

Ringkasan

Kualitas OAuth berasal dari perlindungan seluruh transaksi, bukan tombol login. Gunakan Authorization Code + PKCE, verifikasi state dan nonce, cocokkan redirect URI secara exact, dan simpan token di server. Claude Code mempercepat pekerjaan, tetapi perlu batasan keamanan eksplisit dan review berdasarkan sumber utama.

#Claude Code #OAuth #authentication #security #TypeScript
Gratis

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.