Back to Blog
Security

JWT Authentication for Embedded Widgets: When Signatures Replace Ownership Checks

Learn why JWT signature verification IS authorization for embedded widgets. Understand how cryptographic proof through project-specific secret keys provides multi-tenant isolation without explicit ownership database checks.

Secure Vibe Team
9 min read
JWT Authentication for Embedded Widgets: When Signatures Replace Ownership Checks

Summary

When security auditors review embedded widget APIs, they often flag endpoints for "missing ownership checks." But for JWT-protected widget endpoints, this feedback misses a critical architectural point: JWT signature verification with a project-specific secret key IS the authorization check. This article explains why cryptographic proof replaces traditional database ownership queries in multi-tenant widget architectures.

The Traditional Authorization Pattern

In most APIs, authorization follows a predictable pattern:

async function updateResource(resourceId: string, data: UpdateData) {
  const user = await getCurrentUser();
  const resource = await db.get(resourceId);

  // Explicit ownership check
  if (resource.ownerId !== user.id) {
    throw new ForbiddenError("Not authorized");
  }

  return await db.update(resourceId, data);
}

This pattern makes sense when:

  • You have direct user authentication
  • Resources belong to specific users
  • The request includes a resource ID that needs ownership verification

But embedded widgets operate in a different paradigm entirely.

The Widget Security Challenge

Embedded widgets present a unique security puzzle. Your widget runs on your customer's website, handling actions from their end users. The architecture looks like this:

┌─────────────────────────────────────────────────────────────┐
│  Customer's Website (customer.com)                          │
│  ┌─────────────────────────────────────────────────────────┐│
│  │  Your Embedded Widget                                    ││
│  │  ┌─────────────────────────────────────────────────────┐││
│  │  │  End User: alice@example.com                        │││
│  │  │  Action: Submit feedback, vote, comment             │││
│  │  └─────────────────────────────────────────────────────┘││
│  └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
              ┌─────────────────────────┐
              │  Your Widget API        │
              │  (api.yourservice.com)  │
              └─────────────────────────┘

The challenge: How do you authorize requests when:

  • The end user has no account on your platform
  • Multiple customers embed your widget on different sites
  • Each customer's data must be completely isolated

Two-Tier Security Architecture

Modern embedded widgets solve this with a two-tier approach:

Tier 1: Public Key Authentication (Direct Client)

For lower-risk operations, the widget uses a public key:

// Customer embeds widget with public key
<UserVibesWidget
  projectId="project_abc123"
  publicKey="pk_live_xyz789"
/>

Public keys provide:

  • Origin validation: Only requests from allowed domains are accepted
  • Rate limiting: Per-IP and per-fingerprint throttling
  • Behavioral tracking: Anomaly detection for abuse patterns

This tier is suitable for read operations and public feedback boards.

Tier 2: Secret Key + JWT (Secure Server Proxy)

For authenticated actions, the customer's server acts as a proxy:

┌──────────────────────────────────────────────────────────────────┐
│                      Tier 2: JWT Authentication Flow              │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. End user authenticates on customer's website                 │
│     ┌─────────────┐                                              │
│     │  Customer   │  alice@example.com logs in                   │
│     │  Website    │──────────────────────────────►               │
│     └─────────────┘                                              │
│                                                                  │
│  2. Customer server generates JWT with user data                 │
│     ┌─────────────┐                                              │
│     │  Customer   │  Signs JWT with SECRET KEY (SK)              │
│     │  Server     │  { id, email, name, exp, jti }               │
│     └─────────────┘                                              │
│           │                                                      │
│           ▼                                                      │
│  3. Widget sends JWT to your API                                 │
│     ┌─────────────┐     ┌─────────────┐                          │
│     │   Widget    │────►│  Your API   │                          │
│     │             │     │             │                          │
│     └─────────────┘     └─────────────┘                          │
│                                │                                 │
│  4. API verifies JWT signature with project's secret             │
│     - Fetches project by ID                                      │
│     - Gets project's unique secret key                           │
│     - Verifies HMAC-SHA256 signature                             │
│     - If valid: Request is authorized                            │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

Why JWT Signature IS Authorization

Here is the key insight that trips up many security reviewers:

The JWT signature verification with the project's unique secret key IS the ownership check.

Let us trace the logic:

Step 1: Each Project Gets a Unique Secret Key

When a customer creates a project, the system generates a cryptographically secure secret key:

// During project setup
const secretKey = generateSecureRandomKey(); // 256-bit key
await db.insert("apiKeys", {
  projectId: project._id,
  key: secretKey,
  keyType: "SK", // Secret Key
  isActive: true,
});

This secret key is:

  • Unique to each project: No two projects share a key
  • Known only to the project owner: Displayed once, never stored in plaintext
  • Required for JWT signing: Without it, you cannot create valid JWTs

Step 2: Customer Signs JWTs with Their Secret

On the customer's backend, they sign JWTs containing end-user data:

// Customer's backend code
import jwt from 'jsonwebtoken';

const SECRET_KEY = process.env.USERVIBES_SECRET_KEY; // sk_live_xxx

function generateWidgetToken(user: AuthenticatedUser) {
  return jwt.sign(
    {
      id: user.id,
      email: user.email,
      name: user.name,
      exp: Math.floor(Date.now() / 1000) + 300, // 5 min expiry
      jti: crypto.randomUUID(), // Unique token ID for replay protection
    },
    SECRET_KEY,
    { algorithm: 'HS256' }
  );
}

Step 3: Widget Sends Request with JWT

The widget includes the JWT and project ID in its requests:

// Widget client code
const response = await fetch('https://api.uservibes.com/api/widget/submit', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    projectId: 'project_abc123',
    jwt: signedTokenFromServer,
    fingerprint: deviceFingerprint,
    title: 'Feature request title',
    description: 'Detailed description...',
  }),
});

Step 4: API Verifies Signature with Project's Secret

This is where the "magic" happens. The API:

  1. Extracts the project ID from the request
  2. Fetches the project's unique secret key from the database
  3. Verifies the JWT signature using HMAC-SHA256
async function verifyJWTSignature(
  data: string,
  signature: string,
  secretKey: string
): Promise<boolean> {
  const encoder = new TextEncoder();
  const keyData = encoder.encode(secretKey);
  const dataToVerify = encoder.encode(data);

  const cryptoKey = await crypto.subtle.importKey(
    "raw",
    keyData,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"]
  );

  // Convert base64url signature to bytes
  const signatureBytes = base64UrlDecode(signature);

  return await crypto.subtle.verify(
    "HMAC",
    cryptoKey,
    signatureBytes,
    dataToVerify
  );
}

The Cryptographic Guarantee

If the signature is valid, it mathematically proves that:

  1. The JWT was signed with this specific project's secret key
  2. The caller has possession of that secret key
  3. Therefore, the caller is authorized to act on behalf of this project

An attacker cannot forge a valid JWT for Project A using Project B's secret key. The HMAC-SHA256 signature would be invalid, and the request would be rejected.

Attack Scenario Analysis

Let us examine why traditional ownership checks would be redundant:

Attack: Using Another Project's ID

// Attacker's malicious request
{
  projectId: "victim_project_id", // Victim's project
  jwt: attackerSignedToken,       // Signed with attacker's secret key
  // ...
}

Result: Request REJECTED

The API fetches the victim's project secret key, attempts to verify the JWT signature, and fails because the attacker's JWT was signed with a different key.

Attack: No JWT / Invalid JWT

// Attacker bypasses JWT
{
  projectId: "victim_project_id",
  jwt: "fake_token_or_empty",
  // ...
}

Result: Request REJECTED

The signature verification fails immediately.

Attack: Replay Attack

// Attacker captures a valid JWT and replays it
{
  projectId: "project_id",
  jwt: capturedValidToken, // Already used once
  // ...
}

Result: Request REJECTED

Each JWT includes a unique jti (JWT ID). After first use, the token is marked as consumed in an atomic database transaction:

// Check if token already used
const usedToken = await db
  .query("usedTokens")
  .withIndex("byJti", (q) => q.eq("jti", jti))
  .first();

if (usedToken) {
  await logSecurityEvent("jwt_replay_attack", { jti, projectId });
  return { valid: false, error: "Token already used" };
}

// Atomically mark as used
await db.insert("usedTokens", { jti, projectId, usedAt: Date.now() });

Attack: Algorithm Confusion ("alg: none")

// Attacker creates token with no signature requirement
{
  "alg": "none",
  "typ": "JWT"
}.{
  "id": "attacker",
  "email": "attacker@evil.com"
}.

Result: Request REJECTED

The system explicitly validates the algorithm before signature verification:

const ALLOWED_JWT_ALGORITHMS = ["HS256"];

const algorithm = header.alg || "none";
if (!ALLOWED_JWT_ALGORITHMS.includes(algorithm)) {
  await logSecurityEvent("jwt_algorithm_attack", {
    attemptedAlgorithm: algorithm
  });
  return { valid: false, error: "Invalid algorithm" };
}

When Ownership Checks ARE Needed

This cryptographic authorization model works because of a specific constraint: the secret key grants full project access.

You would need explicit ownership checks if:

  1. Team Access: Multiple users share access to a project with different permission levels
  2. Shared Secrets: Multiple projects somehow share the same secret key (never do this)
  3. Resource-Level Permissions: Individual resources within a project have different access rules

For single-owner projects where the secret key represents full administrative access, the JWT signature is sufficient.

Implementation Best Practices

Short Token Expiry

Limit the damage window if a token is compromised:

const token = jwt.sign(payload, SECRET_KEY, {
  algorithm: 'HS256',
  expiresIn: '5m', // 5 minutes maximum
});

Include JTI for Replay Protection

Always include a unique token identifier:

{
  jti: crypto.randomUUID(),
  // ... other claims
}

Validate All Required Claims

Do not just verify the signature. Validate the payload:

// Required end-user fields
if (!userData.id || !userData.email || !userData.name) {
  return { valid: false, error: "Missing required fields" };
}

// Expiration
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
  return { valid: false, error: "Token expired" };
}

// Not issued in future (clock skew protection)
if (payload.iat && payload.iat > now + 60) {
  return { valid: false, error: "Token issued in future" };
}

Log Security Events

Track and alert on suspicious patterns:

await logSecurityEvent({
  eventType: "jwt_validation_failed",
  severity: "high",
  metadata: {
    fingerprint,
    endpoint,
    errorMessage,
    endUserEmail: decoded?.email,
  },
});

Key Takeaways

  1. JWT signature verification with project-specific keys IS authorization - The signature proves possession of the secret, which proves project ownership

  2. Multi-tenant isolation through cryptography - Each project's unique secret key provides complete isolation without database ownership checks

  3. Defense in depth still matters - Combine signature verification with algorithm validation, replay protection, expiration checks, and security logging

  4. Document your security model - Help future auditors understand that the apparent "missing" ownership check is intentional architecture, not an oversight

  5. Know when this model applies - This works for single-owner projects where the secret key represents full access. Add explicit checks when introducing team access or shared resources


This article is part of the Secure Vibe Coding series. For more security architecture deep-dives, subscribe to our RSS feed.

Share this article

Related Articles

Written by Secure Vibe Team

Published on January 9, 2026