import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { JwtSecretUpdateError, JwtSecretUpdateProgress, JwtSecretUpdateStatus, } from '@supabase/shared-types/out/events' import { useFlag, useParams } from 'common' import { AlertCircle, ChevronDown, CloudOff, ExternalLink, Hourglass, Key, Lightbulb, Loader2, PenTool, Power, RefreshCw, TriangleAlert, } from 'lucide-react' import Link from 'next/link' import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react' import { useForm, type SubmitHandler } from 'react-hook-form' import { toast } from 'sonner' import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Form, FormControl, FormField, FormInputGroupInput, InputGroup, InputGroupAddon, InputGroupText, Modal, } from 'ui' import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' import { JWT_SECRET_UPDATE_ERROR_MESSAGES, JWT_SECRET_UPDATE_PROGRESS_MESSAGES, } from './jwt.constants' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { FormActions } from '@/components/ui/Forms/FormActions' import { InlineLink } from '@/components/ui/InlineLink' import Panel from '@/components/ui/Panel' import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' import { useLegacyAPIKeysStatusQuery } from '@/data/api-keys/legacy-api-keys-status-query' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useJwtSecretUpdateMutation } from '@/data/config/jwt-secret-update-mutation' import { useJwtSecretUpdatingStatusQuery } from '@/data/config/jwt-secret-updating-status-query' import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' import { useLegacyJWTSigningKeyQuery } from '@/data/jwt-signing-keys/legacy-jwt-signing-key-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { uuidv4 } from '@/lib/helpers' const MAX_JWT_EXP = 604800 const formSchema = z.object({ JWT_EXP: z.preprocess( (val) => (val === '' || val === null || val === undefined ? undefined : val), z.coerce .number({ required_error: 'Must have a JWT expiry value', invalid_type_error: 'Must have a JWT expiry value', }) .positive('Must be greater than 0') .max(MAX_JWT_EXP, `Must be less than ${MAX_JWT_EXP}`) ), }) const formId = 'jwt-exp-form' const customJwtSecretFormSchema = z.object({ customToken: z .string() .min(32, 'Must be at least 32 characters') .regex(/^(?!.*[@$]).*$/, '@ and $ are not allowed'), }) const customJwtSecretFormId = 'custom-jwt-secret-form' export const JWTSettings = () => { const { ref: projectRef } = useParams() const disableLegacyJwtSecretRotation = useFlag('disableLegacyJwtSecretRotation') const [customToken, setCustomToken] = useState('') const [isCreatingKey, setIsCreatingKey] = useState(false) const [isRegeneratingKey, setIsGeneratingKey] = useState(false) const { can: canReadJWTSecret } = useAsyncCheckPermissions( PermissionAction.READ, 'field.jwt_secret' ) const { can: canGenerateNewJWTSecret } = useAsyncCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_job.projects.update_jwt' ) const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) const { data } = useJwtSecretUpdatingStatusQuery({ projectRef }) const { data: config, isError } = useProjectPostgrestConfigQuery({ projectRef }) const { mutateAsync: updateJwt, isPending: isSubmittingJwtSecretUpdateRequest } = useJwtSecretUpdateMutation() const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: legacyKey, isPending } = useLegacyJWTSigningKeyQuery( { projectRef }, { enabled: canReadAPIKeys, retry: false } ) const { data: legacyAPIKeysStatus } = useLegacyAPIKeysStatusQuery( { projectRef }, { enabled: canReadAPIKeys } ) const { data: authConfig, isPending: isLoadingAuthConfig } = useAuthConfigQuery({ projectRef }) const { mutate: updateAuthConfig, isPending: isUpdatingAuthConfig } = useAuthConfigUpdateMutation() const { Failed, Updated, Updating } = JwtSecretUpdateStatus const isJwtSecretUpdateFailed = data?.jwtSecretUpdateStatus === Failed const isNotUpdatingJwtSecret = data?.jwtSecretUpdateStatus === undefined || data?.jwtSecretUpdateStatus === Updated const isUpdatingJwtSecret = data?.jwtSecretUpdateStatus === Updating const jwtSecretUpdateErrorMessage = JWT_SECRET_UPDATE_ERROR_MESSAGES[data?.jwtSecretUpdateError as JwtSecretUpdateError] const jwtSecretUpdateProgressMessage = JWT_SECRET_UPDATE_PROGRESS_MESSAGES[data?.jwtSecretUpdateProgress as JwtSecretUpdateProgress] const INITIAL_VALUES = useMemo( () => ({ JWT_EXP: authConfig?.JWT_EXP ?? 3600, }), [authConfig] ) const form = useForm>({ defaultValues: INITIAL_VALUES, resolver: zodResolver(formSchema as any), }) const customJwtSecretForm = useForm>({ defaultValues: { customToken: '' }, resolver: zodResolver(customJwtSecretFormSchema as any), }) const { reset, formState } = form const { isDirty } = formState useEffect(() => { reset(INITIAL_VALUES) }, [INITIAL_VALUES, reset]) const onUpdateJwtExp: SubmitHandler> = async (values) => { if (!projectRef) return console.error('Project ref is required') updateAuthConfig( { projectRef, config: values }, { onError: (error) => { toast.error(`Failed to update JWT expiry: ${error?.message}`) }, onSuccess: (newValues) => { toast.success('Successfully updated JWT expiry') reset({ JWT_EXP: newValues.JWT_EXP ?? values.JWT_EXP }) }, } ) } async function handleJwtSecretUpdate( jwt_secret: string, setModalVisibility: Dispatch> ) { if (!projectRef) return console.error('Project ref is required') const trackingId = uuidv4() try { await updateJwt({ projectRef, jwtSecret: jwt_secret, changeTrackingId: trackingId }) setModalVisibility(false) toast( 'Successfully submitted JWT secret update request. Please wait while your project is updated.' ) } catch (error: any) { toast.error(`Failed to update JWT secret: ${error.message}`) } } return ( <> } >
{isError ? (

Failed to retrieve JWT settings

) : ( <> {legacyKey && legacyKey.status !== 'revoked' && (

Legacy JWT secret can only be changed by rotating to a standby key and then revoking it. It is used to{' '} {legacyKey.status === 'in_use' ? 'sign and verify' : 'only verify'} {' '} JSON Web Tokens by Briven products.

{legacyAPIKeysStatus && legacyAPIKeysStatus.enabled && (

This includes the anon and{' '} service_role JWT based API keys. {' '} Consider switching to publishable and secret API keys to disable them.

)}
)} {legacyKey && legacyKey.status === 'revoked' && ( )} (

How long access tokens are valid for before a refresh token has to be used.

Recommendation: 3600 (1 hour).

} > field.onChange( isNaN(e.target.valueAsNumber) ? '' : e.target.valueAsNumber ) } /> seconds
)} /> )} {!isPending && !legacyKey && ( <> {isUpdatingJwtSecret && (

Updating JWT secret: {jwtSecretUpdateProgressMessage}

)} {isJwtSecretUpdateFailed && ( Please try again. If the failures persist, please contact Briven support with the following details:
Change tracking ID: {data?.changeTrackingId}
Error message: {jwtSecretUpdateErrorMessage}
)}

{disableLegacyJwtSecretRotation ? 'How to migrate to the new API keys?' : 'How to change your JWT secret?'}

{disableLegacyJwtSecretRotation ? 'Migrate to the new publishable and secret API keys to enable rotation with zero downtime and without signing users out. The change is reversible until you revoke the legacy secret.' : 'Instead of changing the legacy JWT secret use a combination of the JWT Signing Keys and API keys features. Consider these advantages:'}

{disableLegacyJwtSecretRotation ? (
  1. Click "Migrate JWT secret" in{' '} JWT Signing Keys .

    This imports your legacy secret into the new system and generates a standby asymmetric key.

  2. Create and roll out new API keys.

    In{' '} API Keys , create a publishable key and secret key, then swap them into your apps in place of anon and{' '} service_role{' '} respectively. Watch the "Last used" indicators to confirm no traffic still depends on the legacy keys.

  3. Click "Rotate keys" in{' '} JWT Signing Keys {' '} to start signing new JWTs with the standby key.

    Existing anon,{' '} service_role, and active user JWTs stay valid. Before rotating, switch any code that verifies JWTs directly against the legacy secret (e.g.{' '} jose,{' '} jsonwebtoken) to{' '} briven.auth.getClaims() or a JWKS-based verifier, and disable the "Verify JWT" setting on any affected Edge Functions.

  4. Optionally, revoke the legacy JWT secret in{' '} JWT Signing Keys {' '} once you're sure it's no longer in use.

) : (
  • Zero-downtime, reversible change.
  • Users remain signed in and bad actors out.
  • Create multiple secret API keys that are immediately revocable and fully covered by audit logs.
  • Private keys and shared secrets are no longer visible by organization members, so they can't leak.
  • Maintain tighter alignment with SOC2 and other security compliance frameworks.
  • Improve app's performance by using public keys to verify JWTs instead of calling getUser().
)}
{disableLegacyJwtSecretRotation ? ( ) : ( } loading={isUpdatingJwtSecret} tooltip={{ content: { side: 'bottom', text: !canGenerateNewJWTSecret ? 'You need additional permissions to generate a new JWT secret' : undefined, }, }} > Change legacy JWT secret setIsGeneratingKey(true)} >

Generate a random secret

setIsCreatingKey(true)} >

Create my own secret

)}
)}
{ setIsGeneratingKey(false) setCustomToken('') }} onConfirm={() => handleJwtSecretUpdate(customToken || 'ROLL', setIsGeneratingKey)} >
  • Use new JWT Signing Keys and API Keys instead

    Consider using a combination of the JWT Signing Keys and API Keys features to achieve the same effect.{' '} Some or all of the warnings listed below might not apply when using these features .

  • Your application will experience significant downtime

    As new anon and service_role keys will be created and the existing ones permanently destroyed, your application will stop functioning for the duration it takes you to swap them.{' '} If you have a mobile, desktop, CLI or any offline-capable application the downtime may be more significant and dependent on app store reviews or user-initiated upgrades or downloads!

    Currently active users will be forcefully signed out (inactive users will keep their sessions).

    All long-lived Storage pre-signed URLs will be permanently invalidated.

  • Your project and database will be restarted

    This process restarts your project, terminating existing connections to your database. You may see API or other unusual errors for{' '} up to 2 minutes while the new secret is deployed.

  • 20-minute cooldown period

    Should you need to revert or repeat this operation, it will take at least 20 minutes before you're able to do so again.

  • Irreversible change! This cannot be undone!

    The old JWT secret will be permanently lost (unless you've saved it prior). Even if you use it again the anon and service_role API keys{' '} will not be restorable to their exact values.

{ setIsCreatingKey(false) setCustomToken('') customJwtSecretForm.reset({ customToken: '' }) }} loading={isSubmittingJwtSecretUpdateRequest} customFooter={
} >
{ setIsGeneratingKey(true) setIsCreatingKey(false) setCustomToken(values.customToken) })} className="flex flex-col space-y-2" noValidate >

Pick a new custom JWT secret. Make sure it is a strong combination of characters that cannot be guessed easily.

( } className="w-full text-left" {...field} /> )} />
) }