Skip to content

Instantly share code, notes, and snippets.

@1WorldCapture
Forked from vpcano/supabase-keygen.sh
Last active February 26, 2026 16:12
Show Gist options
  • Select an option

  • Save 1WorldCapture/ea4c41b359ad7df13bd89db411f126da to your computer and use it in GitHub Desktop.

Select an option

Save 1WorldCapture/ea4c41b359ad7df13bd89db411f126da to your computer and use it in GitHub Desktop.
Script to create a pair of asymmetric JWT keys for self-hosted Supabase
#!/usr/bin/env bash
#
# ==========================================
# SUPABASE ASYMMETRIC KEY GENERATOR (CLI)
# ==========================================
#
# Generates an asymmetric signing key for self-hosted Supabase (ES256/RS256),
# exports:
# - JWT_SIGNING_KEYS (private JWK array for GoTrue)
# - JWT_JWKS (public JWKS for PostgREST/Realtime/Storage)
# and re-signs your existing ANON_KEY and SERVICE_ROLE_KEY JWTs with the new key.
#
# Requirements:
# - bash
# - openssl
# - node >= 16
#
# Security note:
# Passing JWTs via command-line arguments may expose them in shell history / process list.
# Prefer --anon-file/--service-file or environment variables when possible.
#
set -euo pipefail
# -----------------------
# Styling / logging
# -----------------------
NO_COLOR=0
RED="\033[0;31m"
GREEN="\033[0;32m"
YELLOW="\033[1;33m"
BLUE="\033[0;34m"
CYAN="\033[0;36m"
NC="\033[0m" # No Color
OUTPUT_MODE="env" # env | json
log() { printf "%b\n" "$*"; }
err() { printf "%b\n" "$*" >&2; }
die() {
err "${RED}Error:${NC} $*"
exit 1
}
usage() {
cat <<'USAGE'
Asymmetric Key Generator for Self-Hosted Supabase (CLI)
Usage:
supabase-asym-keygen.sh [options]
Required (unless provided via env fallback):
--anon <JWT> Current ANON_KEY token to re-sign
--service <JWT> Current SERVICE_ROLE_KEY token to re-sign
Options:
-a, --alg <ES256|RS256|1|2> Signing algorithm. Default: ES256
--kid <KID> Key ID. If omitted, a random one is generated
--anon-file <path> Read ANON_KEY token from file (newline trimmed)
--service-file <path> Read SERVICE_ROLE_KEY token from file (newline trimmed)
--methods <list> Value printed as JWT_METHODS. Default: HS256,RS256,ES256
--json Output machine-readable JSON only (stdout)
--no-color Disable colored output
-h, --help Show this help and exit
Environment variable fallback (if --anon/--service not provided):
ANON_KEY or SUPABASE_OLD_ANON
SERVICE_ROLE_KEY or SUPABASE_OLD_SERVICE
KID (for --kid)
Examples:
./supabase-asym-keygen.sh --alg ES256 --anon "$ANON_KEY" --service "$SERVICE_ROLE_KEY"
./supabase-asym-keygen.sh -a RS256 --kid key-rs256-1a2b3c4d --anon-file ./anon.jwt --service-file ./service.jwt
Outputs (env mode):
JWT_SIGNING_KEYS='[...]'
JWT_METHODS=HS256,RS256,ES256
JWT_JWKS='{...}'
ANON_KEY=...
SERVICE_ROLE_KEY=...
USAGE
}
# -----------------------
# Argument parsing
# -----------------------
ALG_OPT=""
KID_INPUT=""
OLD_ANON=""
OLD_SERVICE=""
ANON_FILE=""
SERVICE_FILE=""
JWT_METHODS="HS256,RS256,ES256"
while [[ $# -gt 0 ]]; do
case "$1" in
-a|--alg)
[[ $# -ge 2 ]] || die "Missing value for $1"
ALG_OPT="$2"
shift 2
;;
--alg=*)
ALG_OPT="${1#*=}"
shift
;;
--kid)
[[ $# -ge 2 ]] || die "Missing value for $1"
KID_INPUT="$2"
shift 2
;;
--kid=*)
KID_INPUT="${1#*=}"
shift
;;
--anon)
[[ $# -ge 2 ]] || die "Missing value for $1"
OLD_ANON="$2"
shift 2
;;
--anon=*)
OLD_ANON="${1#*=}"
shift
;;
--service|--service-role|--service_role)
[[ $# -ge 2 ]] || die "Missing value for $1"
OLD_SERVICE="$2"
shift 2
;;
--service=*)
OLD_SERVICE="${1#*=}"
shift
;;
--anon-file)
[[ $# -ge 2 ]] || die "Missing value for $1"
ANON_FILE="$2"
shift 2
;;
--anon-file=*)
ANON_FILE="${1#*=}"
shift
;;
--service-file)
[[ $# -ge 2 ]] || die "Missing value for $1"
SERVICE_FILE="$2"
shift 2
;;
--service-file=*)
SERVICE_FILE="${1#*=}"
shift
;;
--methods)
[[ $# -ge 2 ]] || die "Missing value for $1"
JWT_METHODS="$2"
shift 2
;;
--methods=*)
JWT_METHODS="${1#*=}"
shift
;;
--json)
OUTPUT_MODE="json"
shift
;;
--no-color)
NO_COLOR=1
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
*)
err "${RED}Unknown option:${NC} $1"
err ""
usage >&2
exit 2
;;
esac
done
# If JSON mode, keep stdout clean (no banners). Send logs to stderr.
if [[ "$OUTPUT_MODE" == "json" ]]; then
log() { printf "%b\n" "$*" >&2; }
fi
# Disable colors if requested or not a TTY
if [[ "$NO_COLOR" == "1" || ! -t 1 ]]; then
RED=""; GREEN=""; YELLOW=""; BLUE=""; CYAN=""; NC=""
fi
# -----------------------
# Pre-checks
# -----------------------
if ! command -v node &>/dev/null; then
die "Node.js is not installed. Please install Node.js (>=16)."
fi
if ! command -v openssl &>/dev/null; then
die "OpenSSL is not installed."
fi
NODE_MAJOR="$(node -p 'parseInt(process.versions.node.split(".")[0], 10)' 2>/dev/null || echo 0)"
if [[ "${NODE_MAJOR:-0}" -lt 16 ]]; then
die "Node.js version must be >= 16 (detected: $(node -v 2>/dev/null || echo "unknown"))."
fi
# -----------------------
# Load tokens from files / env fallback
# -----------------------
trim_token_file() {
local f="$1"
[[ -f "$f" ]] || die "File not found: $f"
# Remove CR/LF; keep everything else.
tr -d '\r\n' < "$f"
}
if [[ -n "$ANON_FILE" ]]; then
OLD_ANON="$(trim_token_file "$ANON_FILE")"
fi
if [[ -n "$SERVICE_FILE" ]]; then
OLD_SERVICE="$(trim_token_file "$SERVICE_FILE")"
fi
# Env fallback (optional convenience)
if [[ -z "$OLD_ANON" ]]; then
OLD_ANON="${SUPABASE_OLD_ANON:-${ANON_KEY:-}}"
fi
if [[ -z "$OLD_SERVICE" ]]; then
OLD_SERVICE="${SUPABASE_OLD_SERVICE:-${SERVICE_ROLE_KEY:-}}"
fi
if [[ -z "$KID_INPUT" ]]; then
KID_INPUT="${KID:-}"
fi
# -----------------------
# Resolve algorithm
# -----------------------
ALG="ES256"
if [[ -n "$ALG_OPT" ]]; then
case "$(echo "$ALG_OPT" | tr '[:lower:]' '[:upper:]')" in
1|ES256) ALG="ES256" ;;
2|RS256) ALG="RS256" ;;
*)
err "${RED}Invalid --alg value:${NC} $ALG_OPT"
err "Allowed: ES256, RS256 (or 1, 2)."
exit 2
;;
esac
fi
# -----------------------
# Resolve KID
# -----------------------
if [[ -z "$KID_INPUT" ]]; then
# Use openssl rand for deterministic availability
KID="key-$(printf '%s' "$ALG" | tr '[:upper:]' '[:lower:]')-$(openssl rand -hex 4)"
else
KID="$KID_INPUT"
fi
# -----------------------
# Validate inputs
# -----------------------
if [[ -z "$OLD_ANON" || -z "$OLD_SERVICE" ]]; then
err "${RED}Missing required tokens.${NC}"
err ""
err "You must provide BOTH:"
err " --anon <JWT> and --service <JWT>"
err "or via files/env (see --help)."
err ""
usage >&2
exit 2
fi
# Basic shape check (final validation happens in Node)
if [[ "$OLD_ANON" != *.*.* || "$OLD_SERVICE" != *.*.* ]]; then
die "Provided token(s) do not look like JWTs (expected three dot-separated parts)."
fi
# -----------------------
# Workdir / cleanup
# -----------------------
umask 077
WORKDIR="$(mktemp -d 2>/dev/null || mktemp -d -t supabase-asym-keygen)"
KEY_PEM="$WORKDIR/private_key.pem"
PROCESSOR_JS="$WORKDIR/processor.js"
cleanup() {
rm -rf "$WORKDIR"
}
trap cleanup EXIT
# -----------------------
# Banner (env mode only)
# -----------------------
if [[ "$OUTPUT_MODE" == "env" ]]; then
log ""
log "${CYAN}Asymmetric Key Generator for Self-Hosted Supabase${NC}"
log "--------------------------------------------------------"
log "Selected: ${BLUE}${ALG}${NC}"
log "KID: ${BLUE}${KID}${NC}"
fi
# -----------------------
# Generate key
# -----------------------
if [[ "$ALG" == "ES256" ]]; then
openssl ecparam -name prime256v1 -genkey -noout -out "$KEY_PEM"
else
openssl genpkey -algorithm RSA -out "$KEY_PEM" -pkeyopt rsa_keygen_bits:2048
fi
# -----------------------
# Node processor (no token interpolation into JS)
# -----------------------
cat > "$PROCESSOR_JS" <<'EOF'
'use strict';
const fs = require('fs');
const crypto = require('crypto');
function requiredEnv(name) {
const v = process.env[name];
if (!v) throw new Error(`Missing required env: ${name}`);
return v;
}
function decodeJwtPayload(token, roleName) {
const parts = token.trim().split('.');
if (parts.length !== 3) throw new Error(`Invalid ${roleName} token: expected 3 parts`);
// JWT uses base64url
const payloadJson = Buffer.from(parts[1], 'base64url').toString('utf8');
return JSON.parse(payloadJson);
}
try {
const keyPemPath = requiredEnv('KEY_PEM');
const alg = requiredEnv('ALG');
const kid = requiredEnv('KID');
const oldAnon = requiredEnv('OLD_ANON');
const oldService = requiredEnv('OLD_SERVICE');
if (alg !== 'ES256' && alg !== 'RS256') {
throw new Error(`Unsupported alg: ${alg}`);
}
const pem = fs.readFileSync(keyPemPath);
const privateKey = crypto.createPrivateKey(pem);
// 1) Convert PEM to JWK (private)
const jwkPrivate = privateKey.export({ format: 'jwk' });
// Add metadata (Supabase expects these in JWT_SIGNING_KEYS)
jwkPrivate.kid = kid;
jwkPrivate.alg = alg;
jwkPrivate.use = 'sig';
jwkPrivate.key_ops = ['sign'];
// Create public version (JWKS) - remove private parts
const jwkPublic = { ...jwkPrivate };
if (alg === 'RS256') {
delete jwkPublic.d;
delete jwkPublic.p;
delete jwkPublic.q;
delete jwkPublic.dp;
delete jwkPublic.dq;
delete jwkPublic.qi;
} else {
delete jwkPublic.d;
}
delete jwkPublic.key_ops;
const signingKeys = JSON.stringify([jwkPrivate]);
const jwks = JSON.stringify({ keys: [jwkPublic] });
// 2) Re-sign JWTs with new key
function resignToken(oldToken, roleName) {
const payload = decodeJwtPayload(oldToken, roleName);
// Ensure role is correct (security check / normalization)
payload.role = roleName;
const header = {
alg,
typ: 'JWT',
kid
};
const b64Header = Buffer.from(JSON.stringify(header)).toString('base64url');
const b64Payload = Buffer.from(JSON.stringify(payload)).toString('base64url');
const dataToSign = `${b64Header}.${b64Payload}`;
const sign = crypto.createSign(alg === 'RS256' ? 'RSA-SHA256' : 'SHA256');
sign.update(dataToSign);
sign.end();
const signOptions = { key: privateKey };
if (alg === 'RS256') {
signOptions.padding = crypto.constants.RSA_PKCS1_PADDING;
} else {
// JWT ES256 requires raw (r||s) format
signOptions.dsaEncoding = 'ieee-p1363';
}
const signature = sign.sign(signOptions, 'base64url');
return `${dataToSign}.${signature}`;
}
const newAnon = resignToken(oldAnon, 'anon');
const newService = resignToken(oldService, 'service_role');
// Print results as JSON for Bash to consume
console.log(JSON.stringify({
signingKeys,
jwks,
anon: newAnon,
service: newService
}));
} catch (err) {
console.error('Error in Node script:', err && err.message ? err.message : String(err));
process.exit(1);
}
EOF
# -----------------------
# Execute processor
# -----------------------
# (Pass secrets through environment; do not embed into JS file)
OUTPUT="$(
ALG="$ALG" \
KID="$KID" \
OLD_ANON="$OLD_ANON" \
OLD_SERVICE="$OLD_SERVICE" \
KEY_PEM="$KEY_PEM" \
node "$PROCESSOR_JS"
)"
# Extract fields using node (avoid jq dependency)
JWT_SIGNING_KEYS="$(
printf '%s' "$OUTPUT" | node -e 'const fs=require("fs"); const o=JSON.parse(fs.readFileSync(0,"utf8")); process.stdout.write(o.signingKeys);'
)"
JWT_JWKS="$(
printf '%s' "$OUTPUT" | node -e 'const fs=require("fs"); const o=JSON.parse(fs.readFileSync(0,"utf8")); process.stdout.write(o.jwks);'
)"
NEW_ANON="$(
printf '%s' "$OUTPUT" | node -e 'const fs=require("fs"); const o=JSON.parse(fs.readFileSync(0,"utf8")); process.stdout.write(o.anon);'
)"
NEW_SERVICE="$(
printf '%s' "$OUTPUT" | node -e 'const fs=require("fs"); const o=JSON.parse(fs.readFileSync(0,"utf8")); process.stdout.write(o.service);'
)"
# -----------------------
# Output
# -----------------------
if [[ "$OUTPUT_MODE" == "json" ]]; then
# Output a clean JSON object for agents to parse (stdout only)
printf '%s' "$OUTPUT" | ALG="$ALG" KID="$KID" JWT_METHODS="$JWT_METHODS" node -e '
const fs = require("fs");
const o = JSON.parse(fs.readFileSync(0,"utf8"));
const out = {
alg: process.env.ALG,
kid: process.env.KID,
JWT_SIGNING_KEYS: o.signingKeys,
JWT_METHODS: process.env.JWT_METHODS,
JWT_JWKS: o.jwks,
ANON_KEY: o.anon,
SERVICE_ROLE_KEY: o.service
};
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
'
exit 0
fi
# Human-friendly env output (compatible with original script style)
log ""
log "${GREEN}PROCESS COMPLETED!${NC}"
log "1. Copy the content below and append it to your ${YELLOW}.env${NC} file."
log "2. Update your ${YELLOW}docker-compose.yml${NC} services to map these variables as shown in the comments."
log "===================================================================="
log ""
log "# --- 1. AUTH CONFIGURATION (GoTrue) ---"
log "# Map in docker-compose 'auth' service -> GOTRUE_JWT_KEYS"
printf "JWT_SIGNING_KEYS='%s'\n" "$JWT_SIGNING_KEYS"
log ""
log "# Map in docker-compose 'auth' service -> GOTRUE_JWT_VALID_METHODS"
printf "JWT_METHODS=%s\n" "$JWT_METHODS"
log ""
log "# --- 2. SHARED PUBLIC KEYS (Verification) ---"
log "# Map this variable to the following services in docker-compose:"
log "# - 'rest' -> PGRST_JWT_SECRET"
log "# - 'rest' -> PGRST_APP_SETTINGS_JWT_SECRET"
log "# - 'realtime' -> API_JWT_JWKS"
log "# - 'storage' -> JWT_JWKS"
printf "JWT_JWKS='%s'\n" "$JWT_JWKS"
log ""
log "# --- 3. NEW API TOKENS ---"
log "# Replace your old keys in .env (and update client-side apps)"
printf "ANON_KEY=%s\n" "$NEW_ANON"
printf "SERVICE_ROLE_KEY=%s\n" "$NEW_SERVICE"
log ""
log "===================================================================="
log "${YELLOW}Remember to restart your containers to apply changes.${NC}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment