diff --git a/.server-changes/revoked-api-key-grace-period.md b/.server-changes/revoked-api-key-grace-period.md
new file mode 100644
index 00000000000..df8727295ea
--- /dev/null
+++ b/.server-changes/revoked-api-key-grace-period.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: feature
+---
+
+Regenerating a RuntimeEnvironment API key no longer invalidates the previous key immediately. The old key is recorded in a new `RevokedApiKey` table with a 24 hour grace window, and `findEnvironmentByApiKey` falls back to it when the submitted key doesn't match any live environment. The grace window can be ended early (or extended) by updating `expiresAt` on the row.
diff --git a/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx b/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx
index 439fd892f91..52e1f499cbe 100644
--- a/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx
+++ b/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx
@@ -75,8 +75,9 @@ const RegenerateApiKeyModalContent = ({
return (
- {`Regenerating the keys for this environment will temporarily break any live tasks in the
- ${title} environment until the new API keys are set in the relevant environment variables.`}
+ {`A new API key will be issued for the ${title} environment. The previous key stays valid
+ for 24 hours so you can roll out the new key in your environment variables without downtime.
+ After 24 hours, the previous key stops working.`}
{
+ await tx.revokedApiKey.create({
+ data: {
+ apiKey: environment.apiKey,
+ runtimeEnvironmentId: environment.id,
+ expiresAt: revokedApiKeyExpiresAt,
+ },
+ });
+
+ return tx.runtimeEnvironment.update({
+ data: {
+ apiKey: newApiKey,
+ pkApiKey: newPkApiKey,
+ },
+ where: {
+ id: environmentId,
+ },
+ });
});
return updatedEnviroment;
diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts
index f65112b71fc..c919fe4a618 100644
--- a/apps/webapp/app/models/runtimeEnvironment.server.ts
+++ b/apps/webapp/app/models/runtimeEnvironment.server.ts
@@ -11,27 +11,48 @@ export async function findEnvironmentByApiKey(
apiKey: string,
branchName: string | undefined
): Promise {
- const environment = await $replica.runtimeEnvironment.findFirst({
+ const include = {
+ project: true,
+ organization: true,
+ orgMember: true,
+ childEnvironments: branchName
+ ? {
+ where: {
+ branchName: sanitizeBranchName(branchName),
+ archivedAt: null,
+ },
+ }
+ : undefined,
+ } satisfies Prisma.RuntimeEnvironmentInclude;
+
+ let environment = await $replica.runtimeEnvironment.findFirst({
where: {
apiKey,
},
- include: {
- project: true,
- organization: true,
- orgMember: true,
- childEnvironments: branchName
- ? {
- where: {
- branchName: sanitizeBranchName(branchName),
- archivedAt: null,
- },
- }
- : undefined,
- },
+ include,
});
+ // Fall back to keys that were revoked within the grace window
+ if (!environment) {
+ const revokedApiKey = await $replica.revokedApiKey.findFirst({
+ where: {
+ apiKey,
+ expiresAt: { gt: new Date() },
+ },
+ include: {
+ runtimeEnvironment: { include },
+ },
+ });
+
+ environment = revokedApiKey?.runtimeEnvironment ?? null;
+ }
+
+ if (!environment) {
+ return null;
+ }
+
//don't return deleted projects
- if (environment?.project.deletedAt !== null) {
+ if (environment.project.deletedAt !== null) {
return null;
}
@@ -43,7 +64,7 @@ export async function findEnvironmentByApiKey(
return null;
}
- const childEnvironment = environment?.childEnvironments.at(0);
+ const childEnvironment = environment.childEnvironments.at(0);
if (childEnvironment) {
return {
diff --git a/apps/webapp/app/routes/admin.api.v1.revoked-api-keys.$revokedApiKeyId.ts b/apps/webapp/app/routes/admin.api.v1.revoked-api-keys.$revokedApiKeyId.ts
new file mode 100644
index 00000000000..847828d981f
--- /dev/null
+++ b/apps/webapp/app/routes/admin.api.v1.revoked-api-keys.$revokedApiKeyId.ts
@@ -0,0 +1,48 @@
+import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { prisma } from "~/db.server";
+import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
+
+const ParamsSchema = z.object({
+ revokedApiKeyId: z.string(),
+});
+
+const RequestBodySchema = z.object({
+ expiresAt: z.coerce.date(),
+});
+
+export async function action({ request, params }: ActionFunctionArgs) {
+ await requireAdminApiRequest(request);
+
+ const { revokedApiKeyId } = ParamsSchema.parse(params);
+
+ const rawBody = await request.json();
+ const parsedBody = RequestBodySchema.safeParse(rawBody);
+
+ if (!parsedBody.success) {
+ return json({ error: "Invalid request body", issues: parsedBody.error.issues }, { status: 400 });
+ }
+
+ const existing = await prisma.revokedApiKey.findFirst({
+ where: { id: revokedApiKeyId },
+ select: { id: true },
+ });
+
+ if (!existing) {
+ return json({ error: "Revoked API key not found" }, { status: 404 });
+ }
+
+ const updated = await prisma.revokedApiKey.update({
+ where: { id: revokedApiKeyId },
+ data: { expiresAt: parsedBody.data.expiresAt },
+ });
+
+ return json({
+ success: true,
+ revokedApiKey: {
+ id: updated.id,
+ runtimeEnvironmentId: updated.runtimeEnvironmentId,
+ expiresAt: updated.expiresAt.toISOString(),
+ },
+ });
+}
diff --git a/apps/webapp/app/routes/api.v1.auth.jwt.ts b/apps/webapp/app/routes/api.v1.auth.jwt.ts
index e495c9b3688..b95b1eb7877 100644
--- a/apps/webapp/app/routes/api.v1.auth.jwt.ts
+++ b/apps/webapp/app/routes/api.v1.auth.jwt.ts
@@ -36,8 +36,11 @@ export async function action({ request }: LoaderFunctionArgs) {
...parsedBody.data.claims,
};
+ // Sign with the environment's current canonical key, not the raw header key,
+ // so JWTs minted with a revoked (grace-window) key still validate — validation
+ // in jwtAuth.server.ts uses environment.apiKey.
const jwt = await internal_generateJWT({
- secretKey: authenticationResult.apiKey,
+ secretKey: authenticationResult.environment.apiKey,
payload: claims,
expirationTime: parsedBody.data.expirationTime ?? "1h",
});
diff --git a/internal-packages/database/prisma/migrations/20260420000000_add_revoked_api_key_table/migration.sql b/internal-packages/database/prisma/migrations/20260420000000_add_revoked_api_key_table/migration.sql
new file mode 100644
index 00000000000..f3a2ffa199f
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20260420000000_add_revoked_api_key_table/migration.sql
@@ -0,0 +1,24 @@
+-- CreateTable
+CREATE TABLE "RevokedApiKey" (
+ "id" TEXT NOT NULL,
+ "apiKey" TEXT NOT NULL,
+ "runtimeEnvironmentId" TEXT NOT NULL,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "RevokedApiKey_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "RevokedApiKey_apiKey_idx"
+ ON "RevokedApiKey"("apiKey");
+
+-- CreateIndex
+CREATE INDEX "RevokedApiKey_runtimeEnvironmentId_idx"
+ ON "RevokedApiKey"("runtimeEnvironmentId");
+
+-- AddForeignKey
+ALTER TABLE "RevokedApiKey"
+ ADD CONSTRAINT "RevokedApiKey_runtimeEnvironmentId_fkey"
+ FOREIGN KEY ("runtimeEnvironmentId") REFERENCES "RuntimeEnvironment"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma
index 1462752d8dd..9ccf2495d3a 100644
--- a/internal-packages/database/prisma/schema.prisma
+++ b/internal-packages/database/prisma/schema.prisma
@@ -355,6 +355,7 @@ model RuntimeEnvironment {
prompts Prompt[]
errorGroupStates ErrorGroupState[]
taskIdentifiers TaskIdentifier[]
+ revokedApiKeys RevokedApiKey[]
@@unique([projectId, slug, orgMemberId])
@@unique([projectId, shortcode])
@@ -363,6 +364,20 @@ model RuntimeEnvironment {
@@index([organizationId])
}
+/// Records of previously-valid API keys that are still accepted for authentication
+/// during a grace window after rotation. Extend or end the grace period by updating `expiresAt`.
+model RevokedApiKey {
+ id String @id @default(cuid())
+ apiKey String
+ runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ runtimeEnvironmentId String
+ expiresAt DateTime
+ createdAt DateTime @default(now())
+
+ @@index([apiKey])
+ @@index([runtimeEnvironmentId])
+}
+
enum RuntimeEnvironmentType {
PRODUCTION
STAGING