Skip to main content
This guide walks you through migrating existing API keys to Unkey.

Prerequisites

1

Create an Unkey account

Sign up at app.unkey.com
2

Create a workspace and API

Note your workspaceId and apiId: - Workspace ID: Settings → General (upper right corner)
Workspace settings page
  • API ID: APIs → Your API (upper right corner)
API page
3

Create a root key

Go to Settings → Root Keys and create one with api.*.create_key and api.*.verify_key permissions.
Root key creation
4

Get a migration ID

Email support@unkey.com with: - Your workspace ID - Source system (PostgreSQL, Auth0, etc.) - Hash algorithm used We’ll send you a migrationId.

Supported hash formats

VariantDescription
sha256_base64SHA-256, base64 encoded
sha256_hexSHA-256, hex encoded
bcryptbcrypt hash
Need a different hash format? Contact us — we can add support for your algorithm.

Export your keys

Extract key hashes from your current system. Never include plaintext keys.

Example: PostgreSQL

SELECT
  key_hash,
  user_id,
  created_at,
  metadata
FROM api_keys
WHERE revoked = false;

Example: MongoDB

db.apiKeys.find({ revoked: false }, { hash: 1, userId: 1, metadata: 1 });

Hash your keys (if not already hashed)

If you have plaintext keys, hash them before migration:
const { createHash } = require("node:crypto");

function hashKey(plaintext) {
  return {
    value: createHash("sha256").update(plaintext).digest("base64"),
    variant: "sha256_base64",
  };
}

Migrate to Unkey

Use the migration API to import your keys:
const UNKEY_ROOT_KEY = process.env.UNKEY_ROOT_KEY;
const MIGRATION_ID = "mig_..."; // From support
const API_ID = "api_...";

// Your exported keys
const keysToMigrate = [
  {
    hash: {
      value: "abc123...", // The hash value
      variant: "sha256_base64",
    },
    externalId: "user_123", // Link to your user
    meta: {
      plan: "pro",
      migratedFrom: "legacy-system",
    },
    ratelimits: [
      {
        name: "requests",
        limit: 1000,
        duration: 60000,
      },
    ],
  },
  // ... more keys
];

const response = await fetch("https://api.unkey.com/v2/keys.migrateKeys", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${UNKEY_ROOT_KEY}`,
  },
  body: JSON.stringify({
    migrationId: MIGRATION_ID,
    apiId: API_ID,
    keys: keysToMigrate,
  }),
});

const result = await response.json();
if (!response.ok) {
  console.error(
    "Migration failed:",
    result?.error || `HTTP ${response.status}`,
  );
  return;
}
if (!result?.data) {
  console.error("Migration response missing data");
  return;
}
console.log("Migrated:", result.data.migrated.length);
console.log("Failed:", result.data.failed.length);

Response

{
  "meta": { "requestId": "req_..." },
  "data": {
    "migrated": [
      {
        "hash": "abc123...",
        "keyId": "key_..."
      }
    ],
    "failed": [
      {
        "hash": "xyz789...",
        "error": "Key already exists"
      }
    ]
  }
}

Key configuration options

Each key in the migration can include:
FieldDescription
hashRequired. The hash object with value and variant
prefixKey prefix (for display only, not verified)
nameHuman-readable name
externalIdLink to your user/identity
metaCustom JSON metadata
rolesArray of role names
permissionsArray of permission names
ratelimitsRate limit configuration
creditsUsage limit configuration
expiresExpiration timestamp (Unix ms)
enabledWhether key is active (default: true)

Batch migrations

For large migrations, batch your requests:
const BATCH_SIZE = 100;

async function migrateAll(allKeys) {
  const results = { migrated: 0, failed: 0 };

  for (let i = 0; i < allKeys.length; i += BATCH_SIZE) {
    const batch = allKeys.slice(i, i + BATCH_SIZE);

    const response = await fetch("https://api.unkey.com/v2/keys.migrateKeys", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${UNKEY_ROOT_KEY}`,
      },
      body: JSON.stringify({
        migrationId: MIGRATION_ID,
        apiId: API_ID,
        keys: batch,
      }),
    });

    const result = await response.json();
    const batchNum = Math.floor(i / BATCH_SIZE) + 1;

    if (!response.ok) {
      console.error(
        `Batch ${batchNum} failed:`,
        result?.error || `HTTP ${response.status}`,
      );
      results.failed += batch.length;
      continue;
    }
    if (!result?.data) {
      console.error(`Batch ${batchNum} missing data`);
      results.failed += batch.length;
      continue;
    }

    results.migrated += result.data.migrated.length;
    results.failed += result.data.failed.length;

    console.log(`Batch ${batchNum}: ${result.data.migrated.length} migrated`);
  }

  return results;
}

Update your verification

After migration, update your API to verify keys with Unkey:
// Before: Custom verification
const isValid = await myDatabase.verifyKey(apiKey);

// After: Unkey verification
const response = await fetch("https://api.unkey.com/v2/keys.verifyKey", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer " + process.env.UNKEY_ROOT_KEY,
  },
  body: JSON.stringify({ key: apiKey }),
});
const result = await response.json();
if (!response.ok || !result?.data) {
  // Handle error appropriately
  return { valid: false, error: result?.error || "Verification failed" };
}
const isValid = result.data.valid;

Rollback plan

Keep your old verification system running in parallel during migration:
async function verifyKey(apiKey) {
  // Try Unkey first
  const unkeyResult = await verifyWithUnkey(apiKey);

  if (unkeyResult.valid) {
    return unkeyResult;
  }

  // Fall back to legacy system (temporary)
  const legacyResult = await verifyWithLegacy(apiKey);

  if (legacyResult.valid) {
    const maskedKey = apiKey.slice(0, 5) + "..." + apiKey.slice(-5);
    console.log("Key found in legacy, not yet migrated:", maskedKey);
  }

  return legacyResult;
}
Remove the fallback once migration is complete and verified.

Need help?

Contact support

We’ll help you plan and execute your migration safely.
Last modified on March 30, 2026