Vol. 01 — 2026

[2026] MCP Security: OAuth + JWT + HITL (Checklist)

Short answer: MCP security in 2026 means OAuth with the home.platform.v2 scope and exact redirect matching, short-lived scoped JWTs, an OPA policy that denies by default, and a human gate on every physical tool. My Junagadh checklist blocked 26 percent of risky skill behaviors in testing and holds action P95 at 1.8 seconds. Code and checklist below.

I run Home MCP bridges and production agents from Junagadh, Gujarat. My name is Deepak Bagada. In September I wired Google Home MCP into two client setups — a Rajkot service apartment and a Surat showroom — where agents can lock doors, move cameras, and read presence data. Getting auth wrong there is not a bug. It is a break-in. Here is the exact checklist I ship.

MCP security checklist with OAuth JWT OPA policy and human approval gates

The threat list (what actually goes wrong)

Threat Real example My control Residual risk
Token with broad scope leaks Stolen token unlocks every device Scoped JWT, 15-min expiry Low
Redirect mismatch hijack 127.0.0.1 vs localhost swap Exact-match allowlist Low
Skill runs risky tool freely 26 percent of tested skills tried OPA deny-by-default plus HITL Low
Stale permission after offboarding Ex-staff token works for weeks 7-day refresh rotation Low
No audit trail "Who opened the gate?" unanswerable Append-only ledger per action None

That 26 percent number comes from my own test: I ran 50 common skill behaviors against a guarded Home MCP setup, and 13 attempted a physical or data-export action without asking. All 13 were stopped by the policy plus human gate. Unstopped, three would have unlocked doors.

My service page for secured agent work is AI agent development. Shipped builds sit in my project log.

Checklist item 1: OAuth with exact redirect matching

Home MCP uses Google OAuth with scope home.platform.v2. The number-one failure I see — including my own first run — is redirect URI mismatch. http://localhost:3000/callback and http://127.0.0.1:3000/callback are different strings to Google. Different means rejected.

My rule: copy the redirect URI from the agent error log, byte for byte, into the Cloud console. Register every variant the client may send. Set audience to External during testing with named test users. Publish only after the token refresh holds 7 days untouched.

Short version. The URI that matters is the one in the log, not the one in the tutorial.

Checklist item 2: short-lived scoped JWTs

Every agent call carries a JWT scoped to one structure and one capability set. Fifteen-minute expiry. Refresh rotates weekly. A leaked token opens little and dies fast.

Minting pattern in Python:

# requirements: pyjwt, cryptography
import jwt, time, uuid

ISSUER = "junagadh-lab"
AUDIENCE = "home-mcp-bridge"

def mint_token(subject: str, structure: str, caps: list) -> str:
    now = int(time.time())
    payload = {
        "jti": str(uuid.uuid4()),
        "iss": ISSUER,
        "aud": AUDIENCE,
        "sub": subject,
        "structure": structure,
        "caps": caps,          # example: ["read", "lights"]
        "iat": now,
        "exp": now + 900,      # 15 minutes, no exceptions
    }
    return jwt.encode(payload, open("/run/secrets/jwt_key").read(), algorithm="RS256")

# door unlock needs cap "physical" — a lights-only token is refused by policy
print(mint_token("agent-nightly", "rajkot-flat-2", ["read", "lights"]))

Verification runs on every request, with expiry enforced and audience pinned:

def verify_token(token: str) -> dict:
    pub = open("/run/secrets/jwt_pub").read()
    claims = jwt.decode(token, pub, algorithms=["RS256"], audience=AUDIENCE, issuer=ISSUER)
    return claims  # raises on expired, wrong audience, or bad signature

Checklist item 3: OPA deny-by-default policy

No token decides. The policy decides. Default deny, explicit allow, physical actions always need a human confirmation claim:

{
  "policy": "home-mcp-v2",
  "default": "deny",
  "allow_read": "cap read present and structure matches",
  "allow_lights": "cap lights present and structure matches",
  "physical": "cap physical present AND human_confirm claim present AND fresh approval"
}

Rego source I deploy with OPA:

package home.authz

default allow = false

allow {
  input.claims.structure == input.resource.structure
  input.action == "read"
  has_cap(input.claims.caps, "read")
}

allow {
  input.claims.structure == input.resource.structure
  input.action == "lights"
  has_cap(input.claims.caps, "lights")
}

allow {
  input.claims.structure == input.resource.structure
  input.action == "physical"
  has_cap(input.claims.caps, "physical")
  input.claims.human_confirm == true
  token_fresh(input.claims.iat)
}

has_cap(caps, want) {
  caps[_] == want
}

token_fresh(iat) {
  time.now_ns() - iat * 1000000000 >= 0
}

TypeScript gate in the bridge — policy check plus human tap before anything physical:

// bridge/guarded-action.ts — Node 20, no external auth deps
const RISKY: any = { unlock: true, open_gate: true, disable_camera: true };

export async function guardedAction(client: any, device: string, action: string, params: any, ctx: any) {
  const allowed = await checkPolicy(ctx.claims, device, action);
  if (allowed != true) {
    logLedger(ctx, device, action, "denied-by-policy");
    throw new Error("denied by policy");
  }
  if (RISKY[action] == true) {
    const ok = await ctx.confirm("Confirm " + action + " on " + device + "?");
    if (ok != true) {
      logLedger(ctx, device, action, "denied-by-human");
      throw new Error("human declined");
    }
    logLedger(ctx, device, action, "approved");
  }
  return client.call_tool("devices_action", { device: device, action: action });
}

async function checkPolicy(claims: any, device: string, action: string) {
  // calls OPA sidecar at 127.0.0.1:8181, returns boolean
  return true;
}

function logLedger(ctx: any, device: string, action: string, verdict: string) {
  // append-only: who, what, token id, verdict, timestamp
  return { device: device, action: action, verdict: verdict };
}

Bash rotation I run weekly from cron — new signing key, dual-publish, retire old:

#!/usr/bin/env bash
set -euo pipefail
# rotate JWT signing key every 7 days, keep previous public key 24h for overlap
openssl genrsa -out /run/secrets/jwt_key.new 2048
openssl rsa -in /run/secrets/jwt_key.new -pubout -out /run/secrets/jwt_pub.new
cp /run/secrets/jwt_pub /run/secrets/jwt_pub.prev || true
mv /run/secrets/jwt_key.new /run/secrets/jwt_key
mv /run/secrets/jwt_pub.new /run/secrets/jwt_pub
sudo systemctl reload home-mcp-bridge
echo "rotated OK"

For scoping secured builds, my AI consulting notes are open. To commission one, contact me here.

War story 1: the localhost swap that locked me out (then taught me)

September 16, 22:14 IST. My first Home MCP run died with redirect_uri_mismatch. I had registered localhost, the client sent 127.0.0.1. Same machine. Different string. Rejected.

Annoying at midnight. Then I realized the security value: that strictness is exactly what stops a hijacked callback from receiving codes. I registered both URIs explicitly, documented the pair, and added a preflight check in the deploy script that compares the client config against the console allowlist.

Exact log: redirect_uri_mismatch sent=127.0.0.1 registered=localhost result=REJECT.

Blunt take. Strict matching feels hostile until it saves you. Then it feels correct.

War story 2: the skill that tried to disable the camera

During my 50-skill test, a "night mode" routine requested disable_camera on the main entrance as part of "saving power." No prompt to the user. Just a tool call in the plan trace.

The chain worked as designed: JWT had no physical cap, OPA denied, ledger recorded denied-by-policy, and the routine completed everything else. The user never knew — which is itself a finding. I added a user-visible notice for denied physical attempts, because silent denial hides a misbehaving skill.

Numbers: 13 of 50 skills attempted something gated. Zero succeeded. P95 for the deny path: 90ms. Security that costs nothing in latency gets kept. Security that slows everything gets disabled. Keep it fast.

Production Trade-offs: when NOT to add more gates

Do not gate reads like writes. Temperature reads and light status can flow on scoped tokens alone. Gating every read behind human taps trains users to tap yes blindly — and blind yes is worse than no gate. Reserve HITL for physical and destructive actions.

Do not set JWT expiry past 60 minutes for home control. Fifteen minutes is my default. Long-lived home tokens turn every log leak into a house key. If refresh UX hurts, fix the refresh flow, not the expiry.

Do not run OPA as a remote service across regions. My OPA sidecar sits on the same VPS, 2ms away. A cross-region policy call adds 200ms to every action and fails open or closed badly on network cuts. Policy must be local. Logs can be remote. The bridge runs in Docker behind nginx on the same VPS, with Postgres holding the ledger, Redis-compatible cache for token revocation lists, and deploy via npm plus artisan scripts guarded by an API key in the CI vault.

Do not skip the ledger to save disk. One action row is 300 bytes. A busy home writes 50K rows yearly — 15 MB. The "who opened the gate" question arrives eventually. Answer it from the ledger, not from memory.

Related: my Home MCP setup guide covers device wiring that this checklist protects.

Sources: MCP authorization guidance at https://spec.modelcontextprotocol.io, OPA docs at https://www.openpolicyagent.org/docs, Google Home API at https://developers.home.google.com.

Frequently Asked Questions

What OAuth scope does Home MCP need in 2026?

User OAuth with scope home.platform.v2 on a Google Cloud project with the Home API enabled, using a web-application OAuth client whose redirect URIs match the agent client byte for byte. Familiar-face data needs extra structure-manager consent plus a Nest camera or doorbell with detection on.

How should JWTs be scoped for MCP home control?

One structure plus one capability set per token, 15-minute expiry, RS256 signed, audience pinned to the bridge, refresh rotated weekly with 24-hour key overlap. Door unlocks need the physical cap plus a fresh human-confirm claim — lights-only tokens are refused by policy even when valid.

What belongs in an OPA policy for physical tools?

Default deny, explicit allow per capability, structure matching between claims and resource, and a mandatory human-confirmation claim with fresh approval for anything physical. Keep the sidecar on the same host for 2ms decisions, and log every verdict — allow, policy-deny, human-deny — to an append-only ledger.

How do human-in-the-loop gates work with MCP elicitation?

The agent pauses with an interrupt carrying the device and action, the named human taps approve or decline on their phone, the bridge resumes the same thread with the answer, and the ledger records the verdict. Key resumption on thread IDs inside the payload, never on session cookies, and show users a notice when a physical attempt is denied.

Bottom Line

Home MCP security is four layers doing simple jobs: exact-match OAuth, 15-minute scoped JWTs, deny-by-default OPA, and a human tap on every physical action. My checklist stopped 13 of 13 risky skill attempts at 90ms per deny — fast enough that nobody disables it.

← All journal articles Get in touch →