diff --git a/README.md b/README.md
index 08be82ba4..de6a3deb7 100644
--- a/README.md
+++ b/README.md
@@ -430,7 +430,7 @@ The default group to assign all new users to.
 
 ### External Authentication Providers
 
-We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `figma`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication.
+We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `figma`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `nextcloud`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication.
 
 Use the names as the keys underneath `external` to configure each separately.
 
@@ -461,7 +461,7 @@ The URI a OAuth2 provider will redirect to with the `code` and `state` values.
 
 `EXTERNAL_X_URL` - `string`
 
-The base URL used for constructing the URLs to request authorization and access tokens. Used by `gitlab` and `keycloak`. For `gitlab` it defaults to `https://gitlab.com`. For `keycloak` you need to set this to your instance, for example: `https://keycloak.example.com/realms/myrealm`
+The base URL used for constructing the URLs to request authorization and access tokens. Used by `gitlab`, `nextcloud` and `keycloak`. For `gitlab` it defaults to `https://gitlab.com`. For `keycloak` and `nextcloud` you need to set this to your instance, for example: `https://keycloak.example.com/realms/myrealm`
 
 #### Apple OAuth
 
@@ -868,8 +868,8 @@ if AUTOCONFIRM is enabled and the sign up is a duplicate, then the endpoint will
 
 ```json
 {
-  "code":400,
-  "msg":"User already registered"
+  "code": 400,
+  "msg": "User already registered"
 }
 ```
 
@@ -1212,7 +1212,7 @@ Get access_token from external oauth provider
 query params:
 
 ```
-provider=apple | azure | bitbucket | discord | facebook | figma | github | gitlab | google | keycloak | linkedin | notion | slack | spotify | twitch | twitter | workos
+provider=apple | azure | bitbucket | discord | facebook | figma | github | gitlab | google | keycloak | linkedin | nextcloud | notion | slack | spotify | twitch | twitter | workos
 
 scopes=<optional additional scopes depending on the provider (email and name are requested by default)>
 ```
diff --git a/example.env b/example.env
index e645c96e9..07e697af7 100644
--- a/example.env
+++ b/example.env
@@ -114,6 +114,13 @@ GOTRUE_EXTERNAL_KAKAO_CLIENT_ID=""
 GOTRUE_EXTERNAL_KAKAO_SECRET=""
 GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI="http://localhost:9999/callback"
 
+# Nextcloud OAuth config
+GOTRUE_EXTERNAL_NEXTCLOUD_ENABLED="false"
+GOTRUE_EXTERNAL_NEXTCLOUD_CLIENT_ID=""
+GOTRUE_EXTERNAL_NEXTCLOUD_SECRET=""
+GOTRUE_EXTERNAL_NEXTCLOUD_REDIRECT_URI="http://localhost:9999/callback"
+GOTRUE_EXTERNAL_NEXTCLOUD_URL="https://nextcloud.example.com/index.php" # May be of form https://cloud.example.org or https://cloud.example.org/index.php
+
 # Notion OAuth config
 GOTRUE_EXTERNAL_NOTION_ENABLED="false"
 GOTRUE_EXTERNAL_NOTION_CLIENT_ID=""
diff --git a/hack/test.env b/hack/test.env
index 35e4b61c8..e0e3053e1 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -72,6 +72,11 @@ GOTRUE_EXTERNAL_GOOGLE_ENABLED=true
 GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=testclientid
 GOTRUE_EXTERNAL_GOOGLE_SECRET=testsecret
 GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https://identity.services.netlify.com/callback
+GOTRUE_EXTERNAL_NEXTCLOUD_ENABLED=true
+GOTRUE_EXTERNAL_NEXTCLOUD_CLIENT_ID=testclientid
+GOTRUE_EXTERNAL_NEXTCLOUD_SECRET=testsecret
+GOTRUE_EXTERNAL_NEXTCLOUD_REDIRECT_URI=https://identity.services.netlify.com/callback
+GOTRUE_EXTERNAL_NEXTCLOUD_URL=https://nextcloud.example.com/index.php
 GOTRUE_EXTERNAL_NOTION_ENABLED=true
 GOTRUE_EXTERNAL_NOTION_CLIENT_ID=testclientid
 GOTRUE_EXTERNAL_NOTION_SECRET=testsecret
diff --git a/internal/api/external.go b/internal/api/external.go
index 768343d22..e58d3cd52 100644
--- a/internal/api/external.go
+++ b/internal/api/external.go
@@ -564,6 +564,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
 		return provider.NewLinkedinProvider(config.External.Linkedin, scopes)
 	case "linkedin_oidc":
 		return provider.NewLinkedinOIDCProvider(config.External.LinkedinOIDC, scopes)
+	case "nextcloud":
+		return provider.NewNextcloudProvider(config.External.Nextcloud, scopes)
 	case "notion":
 		return provider.NewNotionProvider(config.External.Notion)
 	case "spotify":
diff --git a/internal/api/external_nextcloud_test.go b/internal/api/external_nextcloud_test.go
new file mode 100644
index 000000000..5aa6aee94
--- /dev/null
+++ b/internal/api/external_nextcloud_test.go
@@ -0,0 +1,172 @@
+package api
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+
+	jwt "github.com/golang-jwt/jwt/v5"
+)
+
+const (
+	nextcloudUser           string = `{"ocs":{"data":{"additional_mail":["another@example.com"],"additional_mailScope":["v2-private"],"displayname":"Nextcloud Test","displaynameScope":"v2-private","email":"nextcloud@example.com","emailScope":"v2-private","enabled":true,"id":"123"}}}`
+	nextcloudUserWrongEmail string = `{"ocs":{"data":{"additional_mail":[],"additional_mailScope":["v2-private"],"displayname":"Nextcloud Test","displaynameScope":"v2-private","email":"other@example.com","emailScope":"v2-private","enabled":true,"id":"123"}}}`
+	nextcloudUserNoEmail    string = `{"ocs":{"data":{"additional_mail":[],"additional_mailScope":["v2-private"],"displayname":"Nextcloud Test","displaynameScope":"v2-private","emailScope":"v2-private","enabled":true,"id":"123"}}}`
+)
+
+func (ts *ExternalTestSuite) TestSignupExternalNextcloud() {
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=nextcloud", nil)
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	ts.Require().Equal(http.StatusFound, w.Code)
+	u, err := url.Parse(w.Header().Get("Location"))
+	ts.Require().NoError(err, "redirect url parse failed")
+	q := u.Query()
+	ts.Equal(ts.Config.External.Nextcloud.RedirectURI, q.Get("redirect_uri"))
+	ts.Equal(ts.Config.External.Nextcloud.ClientID, []string{q.Get("client_id")})
+	ts.Equal("code", q.Get("response_type"))
+
+	claims := ExternalProviderClaims{}
+	p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
+	_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
+		return []byte(ts.Config.JWT.Secret), nil
+	})
+	ts.Require().NoError(err)
+
+	ts.Equal("nextcloud", claims.Provider)
+	ts.Equal(ts.Config.SiteURL, claims.SiteURL)
+}
+
+func NextcloudTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/apps/oauth2/api/v1/token":
+			*tokenCount++
+			ts.Equal(code, r.FormValue("code"))
+			ts.Equal("authorization_code", r.FormValue("grant_type"))
+			ts.Equal(ts.Config.External.Nextcloud.RedirectURI, r.FormValue("redirect_uri"))
+
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprint(w, `{"access_token":"nextcloud_token","expires_in":100000}`)
+		case "/ocs/v2.php/cloud/user":
+			*userCount++
+
+			// OCS-APIRequest header is needed for that endpoint, so we check it here
+			ts.Equal("true", r.Header.Get("OCS-APIRequest"))
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprint(w, user)
+		default:
+			w.WriteHeader(500)
+			ts.Fail("unknown nextcloud oauth call %s", r.URL.Path)
+		}
+	}))
+
+	ts.Config.External.Nextcloud.URL = server.URL
+
+	return server
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalNextcloudAuthorizationCode() {
+	// emails from Nextcloud don't return confirm status
+	ts.Config.Mailer.Autoconfirm = true
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "nextcloud", code, "")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "nextcloud@example.com", "Nextcloud Test", "123", "")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalNextcloudDisableSignupErrorWhenNoUser() {
+	ts.Config.DisableSignup = true
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "nextcloud", code, "")
+
+	assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "nextcloud@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalNextcloudDisableSignupErrorWhenEmptyEmail() {
+	ts.Config.DisableSignup = true
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUserNoEmail)
+	defer server.Close()
+
+	u := performAuthorization(ts, "nextcloud", code, "")
+
+	assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "nextcloud@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalNextcloudDisableSignupSuccessWithPrimaryEmail() {
+	ts.Config.DisableSignup = true
+
+	ts.createUser("123", "nextcloud@example.com", "Nextcloud Test", "", "")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "nextcloud", code, "")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "nextcloud@example.com", "Nextcloud Test", "123", "")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalNextcloudSuccessWhenMatchingToken() {
+	// name and rest should be populated from Nextcloud API
+	ts.createUser("123", "nextcloud@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "nextcloud", code, "invite_token")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "nextcloud@example.com", "Nextcloud Test", "123", "")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalNextcloudErrorWhenNoMatchingToken() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUser)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "nextcloud", "invite_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalNextcloudErrorWhenWrongToken() {
+	ts.createUser("123", "nextcloud@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUser)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "nextcloud", "wrong_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalNextcloudErrorWhenEmailDoesntMatch() {
+	ts.createUser("123", "nextcloud@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUserWrongEmail)
+	defer server.Close()
+
+	u := performAuthorization(ts, "nextcloud", code, "invite_token")
+
+	assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
+}
diff --git a/internal/api/provider/nextcloud.go b/internal/api/provider/nextcloud.go
new file mode 100644
index 000000000..563614a6a
--- /dev/null
+++ b/internal/api/provider/nextcloud.go
@@ -0,0 +1,135 @@
+package provider
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+
+	"github.com/supabase/auth/internal/conf"
+	"github.com/supabase/auth/internal/utilities"
+	"golang.org/x/oauth2"
+)
+
+// Nextcloud
+
+type nextcloudProvider struct {
+	*oauth2.Config
+	Host string
+}
+
+type nextcloudUser struct {
+	Email            string   `json:"email"`
+	Name             string   `json:"displayname"`
+	AdditionalEmails []string `json:"additional_mail"`
+	ID               string   `json:"id"`
+	Website          string   `json:"website"`
+	Phone            string   `json:"phone"`
+	Locale           string   `json:"locale"`
+}
+
+type nextcloudUserResponse struct {
+	OCS struct {
+		Data nextcloudUser `json:"data"`
+	} `json:"ocs"`
+}
+
+// NewNextcloudProvider creates a Nextcloud account provider.
+func NewNextcloudProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) {
+	if err := ext.ValidateOAuth(); err != nil {
+		return nil, err
+	}
+
+	oauthScopes := []string{}
+
+	host := chooseHost(ext.URL, "") // May be https://cloud.example.org or https://cloud.example.org/index.php
+	return &nextcloudProvider{
+		Config: &oauth2.Config{
+			ClientID:     ext.ClientID[0],
+			ClientSecret: ext.Secret,
+			Endpoint: oauth2.Endpoint{
+				AuthURL:  host + "/apps/oauth2/authorize",
+				TokenURL: host + "/apps/oauth2/api/v1/token",
+			},
+			RedirectURL: ext.RedirectURI,
+			Scopes:      oauthScopes,
+		},
+		Host: host,
+	}, nil
+}
+
+func (g nextcloudProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
+	return g.Exchange(context.Background(), code)
+}
+
+func (g nextcloudProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
+	var resp nextcloudUserResponse
+
+	err := g.makeOCSRequest(ctx, tok, g.Host+"/ocs/v2.php/cloud/user?format=json", &resp)
+	if err != nil {
+		return nil, err
+	}
+	u := resp.OCS.Data
+	data := &UserProvidedData{}
+
+	// emails can be confirmed, but we don't have that information
+	for _, e := range u.AdditionalEmails {
+		if e != "" {
+			data.Emails = append(data.Emails, Email{Email: e, Verified: true, Primary: false})
+		}
+	}
+
+	if u.Email != "" {
+		data.Emails = append(data.Emails, Email{Email: u.Email, Verified: true, Primary: true})
+	}
+
+	data.Metadata = &Claims{
+		Issuer:  g.Host,
+		Subject: u.ID,
+		Name:    u.Name,
+		Website: u.Website,
+		Phone:   u.Phone,
+		Locale:  u.Locale,
+
+		// To be deprecated
+		FullName:   u.Name,
+		ProviderId: u.ID,
+	}
+
+	return data, nil
+}
+
+func (g nextcloudProvider) makeOCSRequest(ctx context.Context, tok *oauth2.Token, url string, dst interface{}) error {
+
+	// Perform http request, because we neeed to set the Client-Id header
+	req, err := http.NewRequest("GET", url, nil)
+
+	if err != nil {
+		return err
+	}
+
+	req.Header.Set("OCS-APIRequest", "true")
+	req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
+
+	client := &http.Client{Timeout: defaultTimeout}
+	res, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer utilities.SafeClose(res.Body)
+
+	bodyBytes, _ := io.ReadAll(res.Body)
+	res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
+
+	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
+		return httpError(res.StatusCode, string(bodyBytes))
+	}
+
+	if err := json.NewDecoder(res.Body).Decode(dst); err != nil {
+		return err
+	}
+
+	return nil
+
+}
diff --git a/internal/api/settings.go b/internal/api/settings.go
index bc2f38692..8d0f2b59c 100644
--- a/internal/api/settings.go
+++ b/internal/api/settings.go
@@ -18,6 +18,7 @@ type ProviderSettings struct {
 	Kakao          bool `json:"kakao"`
 	Linkedin       bool `json:"linkedin"`
 	LinkedinOIDC   bool `json:"linkedin_oidc"`
+	Nextcloud      bool `json:"nextcloud"`
 	Notion         bool `json:"notion"`
 	Spotify        bool `json:"spotify"`
 	Slack          bool `json:"slack"`
@@ -59,6 +60,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
 			Keycloak:       config.External.Keycloak.Enabled,
 			Linkedin:       config.External.Linkedin.Enabled,
 			LinkedinOIDC:   config.External.LinkedinOIDC.Enabled,
+			Nextcloud:      config.External.Nextcloud.Enabled,
 			Notion:         config.External.Notion.Enabled,
 			Spotify:        config.External.Spotify.Enabled,
 			Slack:          config.External.Slack.Enabled,
diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go
index 767bcf784..d6b7598c0 100644
--- a/internal/api/settings_test.go
+++ b/internal/api/settings_test.go
@@ -32,6 +32,7 @@ func TestSettings_DefaultProviders(t *testing.T) {
 	require.True(t, p.Bitbucket)
 	require.True(t, p.Discord)
 	require.True(t, p.Facebook)
+	require.True(t, p.Nextcloud)
 	require.True(t, p.Notion)
 	require.True(t, p.Spotify)
 	require.True(t, p.Slack)
diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go
index 5ac871c9e..6a76daf2d 100644
--- a/internal/conf/configuration.go
+++ b/internal/conf/configuration.go
@@ -321,6 +321,7 @@ type ProviderConfiguration struct {
 	Gitlab                  OAuthProviderConfiguration     `json:"gitlab"`
 	Google                  OAuthProviderConfiguration     `json:"google"`
 	Kakao                   OAuthProviderConfiguration     `json:"kakao"`
+	Nextcloud               OAuthProviderConfiguration     `json:"nextcloud"`
 	Notion                  OAuthProviderConfiguration     `json:"notion"`
 	Keycloak                OAuthProviderConfiguration     `json:"keycloak"`
 	Linkedin                OAuthProviderConfiguration     `json:"linkedin"`
diff --git a/internal/reloader/testdata/50_example.env b/internal/reloader/testdata/50_example.env
index 1002d8be1..0337ebbe2 100644
--- a/internal/reloader/testdata/50_example.env
+++ b/internal/reloader/testdata/50_example.env
@@ -114,6 +114,13 @@ GOTRUE_EXTERNAL_KAKAO_CLIENT_ID=""
 GOTRUE_EXTERNAL_KAKAO_SECRET=""
 GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI="http://localhost:9999/callback"
 
+# Nextcloud OAuth config
+GOTRUE_EXTERNAL_NEXTCLOUD_ENABLED="false"
+GOTRUE_EXTERNAL_NEXTCLOUD_CLIENT_ID=""
+GOTRUE_EXTERNAL_NEXTCLOUD_SECRET=""
+GOTRUE_EXTERNAL_NEXTCLOUD_REDIRECT_URI="https://localhost:9999/callback"
+GOTRUE_EXTERNAL_NEXTCLOUD_URL="https://nextcloud.example.com/index.php"
+
 # Notion OAuth config
 GOTRUE_EXTERNAL_NOTION_ENABLED="false"
 GOTRUE_EXTERNAL_NOTION_CLIENT_ID=""