-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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