feat(auth): restore key last-used timestamps FE-2462 FE-4315 (#50732)

## Problem

Studio expected aliased fields from the last-used API-key endpoint, but
the live endpoint returns OTEL attribute names. This kept legacy API-key
activity unavailable and prevented Studio from showing activity for new
JWT signing keys. Tracks FE-2462 and FE-4315.

## Fix

Normalize the endpoint response at the data boundary, keep the
`showApiKeysLastUsed` feature flag, and show activity from the past 24
hours for new JWT signing keys. Legacy HS256 signing keys remain blank
because the analytics response does not provide a stable signing-key
record ID for them. The request remains hosted-only, permission-gated,
and non-blocking, and the existing last-rotated column remains intact.

## How to test

- Make a request with a legacy anon or service-role API key, then open
Project Settings > API Keys and verify its last request appears.
- Make an Auth request signed by a new JWT signing key, then open JWT
Keys and verify the matching key shows a Last used timestamp.
- Verify a new key without activity shows No requests in the past 24
hours.
- Verify the legacy HS256 signing-key row leaves Last used blank.
- Expected result: legacy API keys and new JWT signing keys display
activity from the shared endpoint without changing self-hosted Studio.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a **Last used** column for JWT signing keys on supported
platforms.
* Displays usage timestamps, loading and error states, or when a key has
had no requests in the past 24 hours.
  * Usage tracking now includes both API keys and JWT signing keys.
* **Bug Fixes**
  * Improved handling of usage records for legacy and current keys.
* Usage details appear only on supported platforms and for users with
the required permissions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Jordi EnricandJoshen Lim authored and GitHub committed 2026-09-23 13:46:00 +02:00
1 parent cee7461a9a
commit 3063679f1b
7 files changed
+236 -38

No files matched your search

@@ -1,8 +1,8 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { IS_PLATFORM, useFlag, useParams } from 'common'
import { AnimatePresence } from 'framer-motion'
import { AlertCircle, RotateCw, Timer } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
@@ -37,6 +37,10 @@ import { KeyDetailsDialog } from './key-details-dialog'
import { RotateKeyDialog } from './rotate-key-dialog'
import { SigningKeyRow } from './signing-key-row'
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
import {
getJWTSigningKeyLastUsedAt,
useApiKeysLastUsedQuery,
} from '@/data/analytics/api-keys-last-used-query'
import { useLegacyAPIKeysStatusQuery } from '@/data/api-keys/legacy-api-keys-status-query'
import { useJWTSigningKeyDeleteMutation } from '@/data/jwt-signing-keys/jwt-signing-key-delete-mutation'
import { useJWTSigningKeyUpdateMutation } from '@/data/jwt-signing-keys/jwt-signing-key-update-mutation'
@@ -59,11 +63,25 @@ export const JWTSecretKeysTable = () => {
const [selectedKey, setSelectedKey] = useState<JWTSigningKey>()
const [selectedKeyToUpdate, setSelectedKeyToUpdate] = useState<string>()
const [shownDialog, setShownDialog] = useState<DialogType>()
const showApiKeysLastUsed = useFlag('showApiKeysLastUsed')
const { can: canReadAPIKeys, isLoading: isLoadingCanReadAPIKeys } = useAsyncCheckPermissions(
PermissionAction.SECRETS_READ,
'*'
)
const now = useRef(new Date()).current
const {
data: lastUsedData,
isError: isLastUsedError,
isLoading: isLoadingLastUsed,
} = useApiKeysLastUsedQuery(
{
projectRef,
isoTimestampStart: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(),
isoTimestampEnd: now.toISOString(),
},
{ enabled: canReadAPIKeys && showApiKeysLastUsed }
)
const { data: signingKeys, isPending: isLoadingSigningKeys } = useJWTSigningKeysQuery(
{
projectRef,
@@ -129,6 +147,7 @@ export const JWTSecretKeysTable = () => {
() => sortedKeys.filter((key) => key.status === 'revoked'),
[sortedKeys]
)
const getLastUsedAt = (keyId: string) => getJWTSigningKeyLastUsedAt(lastUsedData ?? [], keyId)
const resetDialog = () => {
setSelectedKey(undefined)
@@ -236,7 +255,9 @@ export const JWTSecretKeysTable = () => {
<TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2">
Type
</TableHead>
<TableHead />
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2">
{IS_PLATFORM && showApiKeysLastUsed && 'Last used'}
</TableHead>
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2">
Actions
</TableHead>
@@ -257,6 +278,10 @@ export const JWTSecretKeysTable = () => {
setShownDialog={setShownDialog}
handleStandbyKey={handleStandbyKey}
handlePreviouslyUsedKey={handlePreviouslyUsedKey}
lastUsedAt={getLastUsedAt(standbyKey.id)}
isLoadingLastUsed={isLoadingLastUsed}
isLastUsedError={isLastUsedError}
isLastUsedVisible={IS_PLATFORM && showApiKeysLastUsed}
/>
)}
{inUseKey && (
@@ -269,6 +294,10 @@ export const JWTSecretKeysTable = () => {
handlePreviouslyUsedKey={handlePreviouslyUsedKey}
legacyKey={legacyKey}
standbyKey={standbyKey}
lastUsedAt={getLastUsedAt(inUseKey.id)}
isLoadingLastUsed={isLoadingLastUsed}
isLastUsedError={isLastUsedError}
isLastUsedVisible={IS_PLATFORM && showApiKeysLastUsed}
/>
)}
</AnimatePresence>
@@ -302,6 +331,11 @@ export const JWTSecretKeysTable = () => {
<TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2">
Type
</TableHead>
{IS_PLATFORM && showApiKeysLastUsed && (
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2">
Last used
</TableHead>
)}
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2 hidden lg:table-cell">
Last rotated at
</TableHead>
@@ -323,6 +357,10 @@ export const JWTSecretKeysTable = () => {
setShownDialog={setShownDialog}
handleStandbyKey={handleStandbyKey}
handlePreviouslyUsedKey={handlePreviouslyUsedKey}
lastUsedAt={getLastUsedAt(key.id)}
isLoadingLastUsed={isLoadingLastUsed}
isLastUsedError={isLastUsedError}
isLastUsedVisible={IS_PLATFORM && showApiKeysLastUsed}
/>
))}
</AnimatePresence>
@@ -367,6 +405,11 @@ export const JWTSecretKeysTable = () => {
<TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2">
Type
</TableHead>
{IS_PLATFORM && showApiKeysLastUsed && (
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2">
Last used
</TableHead>
)}
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2 hidden lg:table-cell">
Last rotated at
</TableHead>
@@ -387,6 +430,10 @@ export const JWTSecretKeysTable = () => {
handlePreviouslyUsedKey={handlePreviouslyUsedKey}
legacyKey={legacyKey}
standbyKey={standbyKey}
lastUsedAt={getLastUsedAt(key.id)}
isLoadingLastUsed={isLoadingLastUsed}
isLastUsedError={isLastUsedError}
isLastUsedVisible={IS_PLATFORM && showApiKeysLastUsed}
/>
))}
</AnimatePresence>
@@ -6,6 +6,7 @@ import {
CircleArrowUp,
Eye,
Key,
Minus,
MoreVertical,
ShieldOff,
Timer,
@@ -22,6 +23,7 @@ import {
TableCell,
TableRow,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { AlgorithmHoverCard } from '../algorithm-hover-card'
@@ -38,10 +40,64 @@ interface SigningKeyRowProps {
legacyKey?: JWTSigningKey | null
standbyKey?: JWTSigningKey | null
isLoading?: boolean
lastUsedAt?: number
isLoadingLastUsed?: boolean
isLastUsedError?: boolean
isLastUsedVisible?: boolean
}
const MotionTableRow = motion.create(TableRow)
const hasRotationTimestamp = (status: JWTSigningKey['status']) =>
status === 'previously_used' || status === 'revoked'
const LastUsedCell = ({
lastUsedAt,
isLoading,
isError,
isLastUsedSupported,
}: {
lastUsedAt?: number
isLoading: boolean
isError: boolean
isLastUsedSupported: boolean
}) => {
const className =
'text-right py-2 text-sm text-foreground-light whitespace-nowrap data-[invisible=true]:invisible'
if (!isLastUsedSupported)
return (
<TableCell>
<Minus size={14} className="text-foreground-lighter ml-auto" />
</TableCell>
)
if (isLoading) {
return (
<TableCell aria-label="Loading last used timestamp" className={className}>
<ShimmeringLoader className="w-14 ml-auto" />
</TableCell>
)
}
if (isError) return <TableCell className={className}>Unable to load</TableCell>
if (lastUsedAt === undefined) {
return (
<TableCell className={cn(className, 'text-foreground-lighter')}>No requests in 24h</TableCell>
)
}
return (
<TableCell className={className}>
<TimestampInfo
className="text-sm"
utcTimestamp={new Date(lastUsedAt).toISOString()}
label={dayjs(lastUsedAt).fromNow()}
/>
</TableCell>
)
}
export const SigningKeyRow = ({
signingKey,
setSelectedKey,
@@ -51,6 +107,10 @@ export const SigningKeyRow = ({
legacyKey,
standbyKey,
isLoading = false,
lastUsedAt,
isLoadingLastUsed = false,
isLastUsedError = false,
isLastUsedVisible = false,
}: SigningKeyRowProps) => (
<MotionTableRow
key={signingKey.id}
@@ -107,7 +167,16 @@ export const SigningKeyRow = ({
legacy={signingKey.id === legacyKey?.id}
/>
</TableCell>
{signingKey.status === 'previously_used' || signingKey.status === 'revoked' ? (
{isLastUsedVisible && (
<LastUsedCell
lastUsedAt={lastUsedAt}
isLoading={isLoadingLastUsed}
isError={isLastUsedError}
isLastUsedSupported={signingKey.id !== legacyKey?.id}
/>
)}
{!isLastUsedVisible && !hasRotationTimestamp(signingKey.status) && <TableCell />}
{hasRotationTimestamp(signingKey.status) && (
<TableCell className="max-w-[100px] text-right py-2 text-sm text-foreground-light whitespace-nowrap hidden lg:table-cell">
<TimestampInfo
className="text-sm"
@@ -115,14 +184,13 @@ export const SigningKeyRow = ({
label={dayjs(signingKey.updated_at).fromNow()}
/>
</TableCell>
) : (
<TableCell />
)}
<TableCell className="text-right py-2">
{(signingKey.status !== 'in_use' || signingKey.algorithm !== 'HS256') && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More options"
variant="text"
className="px-1.5"
loading={isLoading}
@@ -1,6 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events'
import { useFlag, useParams } from 'common'
import { IS_PLATFORM, useFlag, useParams } from 'common'
import { AlertCircle, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useMemo, useRef } from 'react'
@@ -53,7 +53,7 @@ export const DisplayApiSettings = ({
const isApiKeysEmpty = apiKeys.length === 0
const now = useRef(new Date()).current
const showApiKeyLastUsed = useFlag('showApiKeysLastUsed')
const showApiKeysLastUsed = useFlag('showApiKeysLastUsed')
const {
isLoading: isLoadingLastUsed,
isError: isLastUsedError,
@@ -64,16 +64,11 @@ export const DisplayApiSettings = ({
isoTimestampStart: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(),
isoTimestampEnd: now.toISOString(),
},
{ enabled: showApiKeyLastUsed }
{ enabled: canReadAPIKeys && showApiKeysLastUsed }
)
const lastUsedAPIKeys = useMemo(() => {
if (
apiKeys.length < 1 ||
!lastUsedLogData ||
lastUsedLogData.length < 1 ||
!showApiKeyLastUsed
) {
if (apiKeys.length < 1 || !lastUsedLogData || lastUsedLogData.length < 1) {
return {}
}
@@ -84,7 +79,7 @@ export const DisplayApiSettings = ({
console.error(e)
return {}
}
}, [lastUsedLogData, apiKeys, showApiKeyLastUsed])
}, [lastUsedLogData, apiKeys])
return (
<Panel
@@ -216,7 +211,7 @@ export const DisplayApiSettings = ({
/>
</FormLayout>
{showApiKeyLastUsed && (
{IS_PLATFORM && showApiKeysLastUsed && (
<div
className="pt-2 text-foreground-lighter w-full text-sm data-[invisible=true]:invisible"
data-invisible={isLoadingLastUsed}
@@ -17,7 +17,7 @@ const serviceRoleKey = { tags: 'service_role', api_key: 'header.payload.services
describe('getLastUsedAPIKeys', () => {
it('returns an empty object when there are no api keys', () => {
expect(
getLastUsedAPIKeys([], [{ timestamp: 1, role: 'anon', signature_prefix: 'anon' }])
getLastUsedAPIKeys([], [{ timestamp: 1, role: 'anon', signaturePrefix: 'anon' }])
).toEqual({})
})
@@ -34,7 +34,7 @@ describe('getLastUsedAPIKeys', () => {
{
timestamp: dayjs().subtract(2, 'hour').valueOf(),
role: 'anon',
signature_prefix: 'anonsig',
signaturePrefix: 'anonsig',
},
]
)
@@ -50,7 +50,7 @@ describe('getLastUsedAPIKeys', () => {
{
timestamp: dayjs().subtract(1, 'day').valueOf(),
role: 'service_role',
signature_prefix: 'servicesig',
signaturePrefix: 'servicesig',
},
]
)
@@ -64,7 +64,7 @@ describe('getLastUsedAPIKeys', () => {
[anonKey],
[
{ timestamp: 1, role: 'anon' },
{ timestamp: 2, signature_prefix: 'anonsig' },
{ timestamp: 2, signaturePrefix: 'anonsig' },
]
)
@@ -18,11 +18,11 @@ export function getLastUsedAPIKeys(
return apiKeys.reduce(
(a, i) => {
const entry = logData?.find(
({ role, signature_prefix }) =>
({ role, signaturePrefix }) =>
role &&
signature_prefix &&
signaturePrefix &&
i.tags.indexOf(role) >= 0 &&
i.api_key.split('.')[2]?.startsWith(signature_prefix)
i.api_key.split('.')[2]?.startsWith(signaturePrefix)
)?.timestamp
if (entry) {
@@ -0,0 +1,53 @@
import { describe, expect, test } from 'vitest'
import { apiKeysLastUsedSchema, getJWTSigningKeyLastUsedAt } from './api-keys-last-used-query'
describe('apiKeysLastUsedSchema', () => {
test('normalizes legacy API key and JWT signing key fields from the live endpoint', () => {
const rows = apiKeysLastUsedSchema.parse([
{
request_sb_apikey_apikey_hash: '',
request_sb_apikey_apikey_prefix: '',
request_sb_jwt_authorization_payload_algorithm: 'HS256',
request_sb_jwt_authorization_payload_key_id: '',
request_sb_jwt_authorization_payload_role: 'anon',
request_sb_jwt_authorization_payload_signature_prefix: 'legacy-signature',
timestamp: 100,
},
{
request_sb_apikey_apikey_hash: '',
request_sb_apikey_apikey_prefix: '',
request_sb_jwt_authorization_payload_algorithm: 'RS256',
request_sb_jwt_authorization_payload_key_id: 'signing-key-id',
request_sb_jwt_authorization_payload_role: 'authenticated',
request_sb_jwt_authorization_payload_signature_prefix: '',
timestamp: 200,
},
{
request_sb_jwt_authorization_payload_key_id: 'signing-key-id',
request_sb_jwt_authorization_payload_role: 'service_role',
timestamp: 300,
},
])
expect(rows).toEqual([
{
role: 'anon',
signaturePrefix: 'legacy-signature',
timestamp: 100,
},
{
keyId: 'signing-key-id',
role: 'authenticated',
timestamp: 200,
},
{
keyId: 'signing-key-id',
role: 'service_role',
timestamp: 300,
},
])
expect(getJWTSigningKeyLastUsedAt(rows, 'signing-key-id')).toBe(300)
})
})
@@ -1,8 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { analyticsKeys } from './keys'
import { get, handleError } from '@/data/fetchers'
import { UseCustomQueryOptions } from '@/types'
import { IS_PLATFORM } from '@/lib/constants'
import type { UseCustomQueryOptions } from '@/types'
export type ApiKeysLastUsedVariables = {
projectRef?: string
@@ -10,13 +12,47 @@ export type ApiKeysLastUsedVariables = {
isoTimestampEnd?: string
}
// One row per (role, signature_prefix). `timestamp` is unix millis of the most
// recent edge-log entry seen for that anon / service_role JWT api key fingerprint.
export type ApiKeyLastUsed = {
timestamp: number
role?: 'anon' | 'service_role' | string
signature_prefix?: string
}
const apiKeyLastUsedSchema = z.object({
timestamp: z.number(),
role: z.string().optional(),
signaturePrefix: z.string().optional(),
keyId: z.string().optional(),
})
export type ApiKeyLastUsed = z.infer<typeof apiKeyLastUsedSchema>
const apiKeyLastUsedEndpointRowSchema = z
.object({
timestamp: z.number().finite(),
role: z.string().nullish(),
signature_prefix: z.string().nullish(),
key_id: z.string().nullish(),
request_sb_jwt_authorization_payload_role: z.string().nullish(),
request_sb_jwt_authorization_payload_signature_prefix: z.string().nullish(),
request_sb_jwt_authorization_payload_key_id: z.string().nullish(),
})
.transform((row): ApiKeyLastUsed => {
const role = row.role || row.request_sb_jwt_authorization_payload_role
const signaturePrefix =
row.signature_prefix || row.request_sb_jwt_authorization_payload_signature_prefix
const keyId = row.key_id || row.request_sb_jwt_authorization_payload_key_id
const apiKeyLastUsed: ApiKeyLastUsed = { timestamp: row.timestamp }
if (role) apiKeyLastUsed.role = role
if (signaturePrefix) apiKeyLastUsed.signaturePrefix = signaturePrefix
if (keyId) apiKeyLastUsed.keyId = keyId
return apiKeyLastUsed
})
.pipe(apiKeyLastUsedSchema)
export const apiKeysLastUsedSchema = z.array(apiKeyLastUsedEndpointRowSchema)
export const getJWTSigningKeyLastUsedAt = (rows: ApiKeyLastUsed[], keyId: string) =>
rows.reduce<number | undefined>((latestTimestamp, row) => {
if (row.keyId !== keyId) return latestTimestamp
if (latestTimestamp === undefined) return row.timestamp
return Math.max(latestTimestamp, row.timestamp)
}, undefined)
export async function getApiKeysLastUsed(
{ projectRef, isoTimestampStart, isoTimestampEnd }: ApiKeysLastUsedVariables,
@@ -42,18 +78,17 @@ export async function getApiKeysLastUsed(
if (error) handleError(error)
const response = data as { error?: string | object | null; result?: unknown[] }
if (response?.error) {
if (data?.error) {
throw new Error(
typeof response.error === 'string' ? response.error : 'Failed to fetch last-used API keys'
typeof data.error === 'string' ? data.error : 'Failed to fetch last-used API keys'
)
}
return (response?.result ?? []) as ApiKeyLastUsed[]
return apiKeysLastUsedSchema.parse(data?.result ?? [])
}
export type ApiKeysLastUsedData = Awaited<ReturnType<typeof getApiKeysLastUsed>>
export type ApiKeysLastUsedError = unknown
export type ApiKeysLastUsedError = Error
export const useApiKeysLastUsedQuery = <TData = ApiKeysLastUsedData>(
{ projectRef, isoTimestampStart, isoTimestampEnd }: ApiKeysLastUsedVariables,
@@ -66,6 +101,6 @@ export const useApiKeysLastUsedQuery = <TData = ApiKeysLastUsedData>(
queryKey: analyticsKeys.apiKeysLastUsed(projectRef, { isoTimestampStart, isoTimestampEnd }),
queryFn: ({ signal }) =>
getApiKeysLastUsed({ projectRef, isoTimestampStart, isoTimestampEnd }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
enabled: IS_PLATFORM && enabled && typeof projectRef !== 'undefined',
...options,
})