Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ supports. PostgREST receives only public JWKS. PowerSync uses the Auth JWKS URL,
audience `authenticated`, and disabled Supabase HMAC mode; it has no JWT
secret environment variable.

`publishableKey` and `secretKey` use Supabase's canonical self-hosted opaque-key
format: their role-specific prefix is followed by exactly 22 unpadded
Base64URL random characters, a fixed underscore separator, and exactly 8
unpadded Base64URL checksum characters (total lengths 46 and 41 respectively).
The checksum is the first 8 Base64URL characters of SHA-256 over the literal
`supabase-self-hosted|<complete prefix plus random segment>` context, matching
the pinned self-hosted v0.7.0 generation and rotation scripts. Validation is
strict with no legacy format. Existing projects must rotate both opaque keys
and their matching consumers before deploying the strict validator; this does
not rotate ES256 signing keys or invalidate sessions.

`auth.accessTokenExpirationSeconds` defaults to 3600. Security-owned GoTrue
JWT environment names are rejected in `goTrueEnv`; provider-specific settings
remain supported.
Expand Down
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ The Secret contains exactly these string fields:
| Key | Meaning |
| --- | --- |
| `signingKeys` | JSON array containing exactly one signing-capable P-256 ES256 private JWK with a non-empty `kid` |
| `publishableKey` | Opaque `sb_publishable_*` client credential |
| `secretKey` | Opaque `sb_secret_*` backend credential, distinct from the publishable key |
| `publishableKey` | Canonical `sb_publishable_<22 Base64URL random>_<8 Base64URL checksum>` client credential (46 characters) |
| `secretKey` | Canonical `sb_secret_<22 Base64URL random>_<8 Base64URL checksum>` backend credential (41 characters), distinct from the publishable key |
| `anonRoleJwt` | ES256 JWT for role `anon`, audience `authenticated` |
| `serviceRoleJwt` | ES256 JWT for role `service_role`, audience `authenticated` |

Expand All @@ -58,6 +58,30 @@ errors set `SecretsReady=False` without putting credential contents in status.
The fallback value is used only by GoTrue; it is not part of the signing-key
array or public JWKS.

### Canonical opaque API keys

The opaque values are the self-hosted format used by Supabase. After the
role-specific prefix, each key has exactly 22 unpadded Base64URL characters,
one underscore separator at that fixed boundary, and exactly 8 unpadded
Base64URL checksum characters. The random segment may itself contain
underscores. The checksum is the first 8 Base64URL characters of SHA-256 over
`supabase-self-hosted|<complete prefix plus random segment>`. This literal
context and algorithm match the pinned Supabase self-hosted v0.7.0 scripts:
[`add-new-auth-keys.sh`](https://github.com/supabase/supabase/blob/self-hosted/v0.7.0/docker/utils/add-new-auth-keys.sh#L125-L139)
and
[`rotate-new-api-keys.sh`](https://github.com/supabase/supabase/blob/self-hosted/v0.7.0/docker/utils/rotate-new-api-keys.sh#L60-L76).
The format is also documented in Supabase's
[`self-hosted-auth-keys.mdx`](https://github.com/supabase/supabase/blob/master/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx#L78-L85).
The evidence and enforcement boundary are collected in
[`docs/research/supabase-self-hosted-opaque-api-keys.md`](docs/research/supabase-self-hosted-opaque-api-keys.md).

The operator rejects noncanonical opaque values before reconciling dependent
workloads; there is no legacy or dual-format compatibility mode. Before
deploying an operator image with this strict validator, rotate both opaque
keys for every existing project and atomically update the matching client and
backend consumers. This rotation is independent of the ES256 signing key and
does not invalidate user sessions.

The operator does not integrate with Infisical. A typical deployment stores
the five values at one Infisical project/environment path and uses the
Infisical Kubernetes operator to synchronize them into the orphaned Secret
Expand Down Expand Up @@ -223,4 +247,6 @@ There is no compatibility or hybrid mode. Replace manifests using
and `gateway`, provision the five external credential fields, and plan a
coordinated client cutover to opaque keys and ES256 sessions. Existing
databases can be retained and readopted; callers must not expect old HS256
tokens or Kong routes to continue working.
tokens or Kong routes to continue working. Existing projects with
noncanonical opaque keys must complete the two-key rotation and matching
consumer cutover before the strict operator image is rolled out.
79 changes: 79 additions & 0 deletions docs/research/supabase-self-hosted-opaque-api-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Supabase self-hosted opaque API keys

## Question

What exact `sb_publishable_*` and `sb_secret_*` format does current self-hosted
Supabase generate, what does its checksum cover, and which part of the stack
enforces it?

This note records the primary-source evidence behind CloudNative Supabase's
project-credential validation. It is not a second configuration source.

## Verified format

Supabase documents these forms:

```text
sb_publishable_<22-character-random>_<8-character-checksum>
sb_secret_<22-character-random>_<8-character-checksum>
```

Both variable segments use the unpadded Base64URL alphabet: ASCII letters,
digits, `-`, and `_`. The total lengths are 46 characters for a publishable key
and 41 for a secret key.

The pinned self-hosted v0.7.0 scripts generate 17 cryptographically random
bytes, encode them as unpadded Base64URL, and take the first 22 characters. For
both key roles they then compute:

```text
base64url(sha256("supabase-self-hosted|" + prefix + random))[0:8]
```

The prefix in that expression includes its trailing underscore. The final
underscore between the random and checksum segments sits at a fixed position;
the random segment may itself contain underscores.

The checksum context is the literal `supabase-self-hosted`. It is not derived
from a Kubernetes namespace, `SupabaseProject` name, hostname, or a hosted
Supabase project reference.

## Enforcement boundary

The upstream self-hosted gateway does not recompute this checksum. Envoy
compares the supplied opaque key with its configured value exactly. The
checksum is therefore a canonical-format and typo-detection mechanism, not an
additional authorization primitive.

CloudNative Supabase deliberately validates the canonical format and checksum
when it reads the external five-field project credential Secret. That catches
bad provisioning before dependent workloads change while leaving Envoy's
runtime exact-match semantics aligned with upstream.

There is no legacy-format fallback. A project using a prefix-only, readable,
or otherwise noncanonical value must rotate both opaque keys and update its
matching client and backend consumers before deploying a strict operator
image. This rotation is independent of the ES256 signing key and does not by
itself invalidate user sessions.

## Boundaries and unknowns

Hosted Supabase uses the same visible key shape, but its platform-side issuance
implementation is not public. CloudNative Supabase follows the documented
self-hosted algorithm and does not infer hosted-project checksum inputs.

This research does not change ES256 signing keys, internal role JWTs, JWKS,
GoTrue's compatibility secret, or Envoy authorization behavior.

## Primary sources

- Format and the explicit gateway checksum note:
[self-hosted-auth-keys.mdx](https://github.com/supabase/supabase/blob/master/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx#L78-L85)
- Pinned initial-generation implementation:
[self-hosted/v0.7.0 add-new-auth-keys.sh](https://github.com/supabase/supabase/blob/self-hosted/v0.7.0/docker/utils/add-new-auth-keys.sh#L125-L139)
- Pinned independent rotation implementation:
[self-hosted/v0.7.0 rotate-new-api-keys.sh](https://github.com/supabase/supabase/blob/self-hosted/v0.7.0/docker/utils/rotate-new-api-keys.sh#L60-L76)
- Envoy's exact-value comparison:
[lds.template.yaml](https://github.com/supabase/supabase/blob/master/docker/volumes/api/envoy/lds.template.yaml#L732-L751)
- Opaque API-key semantics:
[api-keys.mdx](https://github.com/supabase/supabase/blob/master/apps/docs/content/guides/getting-started/api-keys.mdx#L46-L57)
8 changes: 4 additions & 4 deletions internal/controller/modern_platform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -686,8 +686,8 @@ func TestInvalidCredentialRotationLeavesExistingWorkloadsUnchanged(t *testing.T)
}
invalidCredentials := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: project.Spec.ProjectCredentialsSecret, Namespace: project.Namespace}, Data: map[string][]byte{
secretresources.ProjectCredentialsSigningKeysKey: []byte("{"),
secretresources.ProjectCredentialsPublishableKey: []byte("sb_publishable_previous"),
secretresources.ProjectCredentialsSecretKey: []byte("sb_secret_previous"),
secretresources.ProjectCredentialsPublishableKey: []byte("sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j"),
secretresources.ProjectCredentialsSecretKey: []byte("sb_secret_Z9_y8-X7w6_V5u4-T3s2_R_6LqoZ8QA"),
secretresources.ProjectCredentialsAnonRoleJWTKey: []byte("previous.anon.jwt"),
secretresources.ProjectCredentialsServiceRoleJWTKey: []byte("previous.service.jwt"),
}}
Expand Down Expand Up @@ -879,8 +879,8 @@ func validProjectCredentialsSecret(t *testing.T, project *supabasev1alpha1.Supab
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: project.Namespace},
Data: map[string][]byte{
secretresources.ProjectCredentialsSigningKeysKey: []byte(keys),
secretresources.ProjectCredentialsPublishableKey: []byte("sb_publishable_fixture"),
secretresources.ProjectCredentialsSecretKey: []byte("sb_secret_fixture"),
secretresources.ProjectCredentialsPublishableKey: []byte("sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j"),
secretresources.ProjectCredentialsSecretKey: []byte("sb_secret_Z9_y8-X7w6_V5u4-T3s2_R_6LqoZ8QA"),
secretresources.ProjectCredentialsAnonRoleJWTKey: []byte(sign("anon")),
secretresources.ProjectCredentialsServiceRoleJWTKey: []byte(sign("service_role")),
},
Expand Down
10 changes: 5 additions & 5 deletions internal/resources/configmaps/envoy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ func TestRenderedEnvoyFixtureImplementsOpaqueGatewaySemantics(t *testing.T) {
project := &supabasev1alpha1.SupabaseProject{ObjectMeta: metav1.ObjectMeta{Name: "fixture", Namespace: "default"}}
config := BuildEnvoyConfigMap(project)
values := map[string]string{
"${SUPABASE_PUBLISHABLE_KEY}": "sb_publishable_fixture",
"${SUPABASE_SECRET_KEY}": "sb_secret_fixture",
"${SUPABASE_PUBLISHABLE_KEY}": "sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j",
"${SUPABASE_SECRET_KEY}": "sb_secret_Z9_y8-X7w6_V5u4-T3s2_R_6LqoZ8QA",
"${SUPABASE_ANON_ROLE_JWT}": "eyJanon.fixture.token",
"${SUPABASE_SERVICE_ROLE_JWT}": "eyJservice.fixture.token",
}
Expand Down Expand Up @@ -176,8 +176,8 @@ func TestRenderedEnvoyFixtureImplementsOpaqueGatewaySemantics(t *testing.T) {
for _, test := range []struct {
key, want string
}{
{"sb_publishable_fixture", "eyJanon.fixture.token"},
{"sb_secret_fixture", "eyJservice.fixture.token"},
{"sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j", "eyJanon.fixture.token"},
{"sb_secret_Z9_y8-X7w6_V5u4-T3s2_R_6LqoZ8QA", "eyJservice.fixture.token"},
} {
if got, ok := translateOpaqueFixture(test.key, values, true); !ok || got != test.want {
t.Fatalf("opaque key %q translated to %q (ok=%v), want %q", test.key, got, ok, test.want)
Expand All @@ -190,7 +190,7 @@ func TestRenderedEnvoyFixtureImplementsOpaqueGatewaySemantics(t *testing.T) {
authorization, translated, want string
}{
{"Bearer eyJuser.claims.signature", "eyJanon.fixture.token", "Bearer eyJuser.claims.signature"},
{"Bearer sb_publishable_fixture", "eyJanon.fixture.token", "Bearer eyJanon.fixture.token"},
{"Bearer sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j", "eyJanon.fixture.token", "Bearer eyJanon.fixture.token"},
{"Basic abc", "eyJservice.fixture.token", "Bearer eyJservice.fixture.token"},
{"", "eyJanon.fixture.token", "Bearer eyJanon.fixture.token"},
} {
Expand Down
57 changes: 52 additions & 5 deletions internal/resources/secrets/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import (
corev1 "k8s.io/api/core/v1"
)

const (
fixturePublishableKey = "sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j"
fixtureSecretKey = "sb_secret_Z9_y8-X7w6_V5u4-T3s2_R_6LqoZ8QA"
)

func TestValidateProjectCredentials(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
Expand All @@ -26,11 +31,14 @@ func TestValidateProjectCredentials(t *testing.T) {
}
bundle := map[string]string{
ProjectCredentialsSigningKeysKey: mustMarshal(t, []any{private}),
ProjectCredentialsPublishableKey: "sb_publishable_fixture",
ProjectCredentialsSecretKey: "sb_secret_fixture",
ProjectCredentialsPublishableKey: fixturePublishableKey,
ProjectCredentialsSecretKey: fixtureSecretKey,
ProjectCredentialsAnonRoleJWTKey: signRole(t, key, "anon"),
ProjectCredentialsServiceRoleJWTKey: signRole(t, key, "service_role"),
}
if len(bundle[ProjectCredentialsPublishableKey]) != 46 || len(bundle[ProjectCredentialsSecretKey]) != 41 {
t.Fatalf("canonical fixture lengths = %d and %d, want 46 and 41", len(bundle[ProjectCredentialsPublishableKey]), len(bundle[ProjectCredentialsSecretKey]))
}
projection, err := ValidateProjectCredentials(&corev1.Secret{Data: stringData(bundle)})
if err != nil {
t.Fatalf("ValidateProjectCredentials() error = %v", err)
Expand Down Expand Up @@ -74,8 +82,41 @@ func TestValidateProjectCredentials(t *testing.T) {
"expired": func(values map[string]string) {
values[ProjectCredentialsAnonRoleJWTKey] = signRoleWithClaims(t, key, "anon", "authenticated", "fixture", 1)
},
"wrong secret prefix": func(values map[string]string) { values[ProjectCredentialsSecretKey] = "sb_publishable_other" },
"wrong publishable prefix": func(values map[string]string) { values[ProjectCredentialsPublishableKey] = "jwt_publishable_other" },
"wrong secret prefix": func(values map[string]string) {
values[ProjectCredentialsSecretKey] = fixturePublishableKey
},
"wrong publishable prefix": func(values map[string]string) {
values[ProjectCredentialsPublishableKey] = "jwt_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j"
},
"short random segment": func(values map[string]string) {
values[ProjectCredentialsPublishableKey] = canonicalOpaqueFixture("sb_publishable_", "short")
},
"long random segment": func(values map[string]string) {
values[ProjectCredentialsPublishableKey] = canonicalOpaqueFixture("sb_publishable_", "ABCDEFGHIJKLMNOPQRSTUVW")
},
"short checksum": func(values map[string]string) {
values[ProjectCredentialsPublishableKey] = "sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5"
},
"long checksum": func(values map[string]string) {
values[ProjectCredentialsPublishableKey] = "sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5jA"
},
"invalid random character": func(values map[string]string) {
values[ProjectCredentialsPublishableKey] = canonicalOpaqueFixture("sb_publishable_", "A1_b2-C3d4_E5f6-G7h8I!")
},
"invalid checksum character": func(values map[string]string) {
values[ProjectCredentialsPublishableKey] = "sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j!j"
},
"misplaced fixed-boundary separator": func(values map[string]string) {
key := []byte(fixturePublishableKey)
key[len("sb_publishable_")+5] = '_'
key[len("sb_publishable_")+22] = 'A'
values[ProjectCredentialsPublishableKey] = string(key)
},
"checksum mismatch": func(values map[string]string) {
key := []byte(fixturePublishableKey)
key[len(key)-1] = 'A'
values[ProjectCredentialsPublishableKey] = string(key)
},
}
for _, field := range RequiredProjectCredentialKeys {
mutations["missing "+field] = func(values map[string]string) { delete(values, field) }
Expand All @@ -88,13 +129,19 @@ func TestValidateProjectCredentials(t *testing.T) {
if err == nil {
t.Fatal("invalid credential bundle was accepted")
}
if strings.Contains(err.Error(), "sb_publishable_fixture") || strings.Contains(err.Error(), "sb_secret_fixture") || strings.Contains(err.Error(), "eyJ") {
if strings.Contains(err.Error(), fixturePublishableKey) || strings.Contains(err.Error(), fixtureSecretKey) || strings.Contains(err.Error(), "eyJ") {
t.Fatalf("validation error exposed credential material: %v", err)
}
})
}
}

func canonicalOpaqueFixture(prefix, random string) string {
digest := sha256.Sum256([]byte("supabase-self-hosted|" + prefix + random))
checksum := base64.RawURLEncoding.EncodeToString(digest[:])[:8]
return prefix + random + "_" + checksum
}

func stringData(values map[string]string) map[string][]byte {
result := make(map[string][]byte, len(values))
for key, value := range values {
Expand Down
48 changes: 40 additions & 8 deletions internal/resources/secrets/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ const (

const emailHookSecretBytes = 32

const (
opaqueKeyChecksumContext = "supabase-self-hosted"
opaqueKeyRandomLength = 22
opaqueKeyChecksumLength = 8
)

// ProjectCredentials is the validated, transient projection of the external
// credential bundle. Secret values are never written to project status.
type ProjectCredentials struct {
Expand Down Expand Up @@ -170,19 +176,45 @@ func fieldError(field, reason string) error {
}

func validateOpaqueKey(value, prefix string) error {
if !strings.HasPrefix(value, prefix) || len(value) == len(prefix) {
return fmt.Errorf("must start with %s and include a non-empty suffix", prefix)
}
for _, r := range value[len(prefix):] {
isLetter := (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')
isDigit := r >= '0' && r <= '9'
if !isLetter && !isDigit && !strings.ContainsRune("._-", r) {
return fmt.Errorf("contains an invalid character")
if !strings.HasPrefix(value, prefix) {
return fmt.Errorf("must start with the canonical prefix")
}

expectedLength := len(prefix) + opaqueKeyRandomLength + 1 + opaqueKeyChecksumLength
if len(value) != expectedLength {
return fmt.Errorf("must be exactly %d characters", expectedLength)
}

randomStart := len(prefix)
randomEnd := randomStart + opaqueKeyRandomLength
if value[randomEnd] != '_' {
return fmt.Errorf("must contain a separator after the 22-character random segment")
}
for _, r := range value[randomStart:randomEnd] {
if !isBase64URLCharacter(r) {
return fmt.Errorf("random segment contains an invalid Base64URL character")
}
}
checksum := value[randomEnd+1:]
for _, r := range checksum {
if !isBase64URLCharacter(r) {
return fmt.Errorf("checksum contains an invalid Base64URL character")
}
}

digest := sha256.Sum256([]byte(opaqueKeyChecksumContext + "|" + value[:randomEnd]))
expectedChecksum := base64.RawURLEncoding.EncodeToString(digest[:])[:opaqueKeyChecksumLength]
if checksum != expectedChecksum {
return fmt.Errorf("checksum does not match the canonical key")
}
return nil
}

func isBase64URLCharacter(r rune) bool {
return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_'
}

// GenerateGoTrueFallbackSecret creates the independent random fallback value.
// It is intentionally not included in the project credential bundle.
func GenerateGoTrueFallbackSecret(project *supabasev1alpha1.SupabaseProject) (*corev1.Secret, string, error) {
Expand Down
Loading