diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index b21c376002..9945eb4949 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -459,6 +459,7 @@ authorize_application = Authorize Application
 authorize_redirect_notice = You will be redirected to %s if you authorize this application.
 authorize_application_created_by = This application was created by %s.
 authorize_application_description = If you grant the access, it will be able to access and write to all your account information, including private repos and organisations.
+authorize_application_with_scopes = With scopes: %s
 authorize_title = Authorize "%s" to access your account?
 authorization_failed = Authorization failed
 authorization_failed_desc = The authorization failed because we detected an invalid request. Please contact the maintainer of the app you have tried to authorize.
diff --git a/routers/web/auth/oauth2_provider.go b/routers/web/auth/oauth2_provider.go
index 2ccc4a2253..1aebc047bd 100644
--- a/routers/web/auth/oauth2_provider.go
+++ b/routers/web/auth/oauth2_provider.go
@@ -104,7 +104,18 @@ func InfoOAuth(ctx *context.Context) {
 		Picture:           ctx.Doer.AvatarLink(ctx),
 	}
 
-	groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)
+	var accessTokenScope auth.AccessTokenScope
+	if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
+		auths := strings.Fields(auHead)
+		if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
+			accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, auths[1])
+		}
+	}
+
+	// since version 1.22 does not verify if groups should be public-only,
+	// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
+	onlyPublicGroups, _ := accessTokenScope.PublicOnly()
+	groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
 	if err != nil {
 		ctx.ServerError("Oauth groups for user", err)
 		return
@@ -304,6 +315,9 @@ func AuthorizeOAuth(ctx *context.Context) {
 		return
 	}
 
+	// check if additional scopes
+	ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll
+
 	// show authorize page to grant access
 	ctx.Data["Application"] = app
 	ctx.Data["RedirectURI"] = form.RedirectURI
diff --git a/services/auth/basic.go b/services/auth/basic.go
index 1f6c3a442d..6a05b2fe53 100644
--- a/services/auth/basic.go
+++ b/services/auth/basic.go
@@ -77,8 +77,8 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
 		log.Trace("Basic Authorization: Attempting login with username as token")
 	}
 
-	// check oauth2 token
-	uid := CheckOAuthAccessToken(req.Context(), authToken)
+	// get oauth2 token's user's ID
+	_, uid := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken)
 	if uid != 0 {
 		log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
 
diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go
index 251ae5a244..6f2cadd4ab 100644
--- a/services/auth/oauth2.go
+++ b/services/auth/oauth2.go
@@ -26,33 +26,35 @@ var (
 	_ Method = &OAuth2{}
 )
 
-// CheckOAuthAccessToken returns uid of user from oauth token
-func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
+// GetOAuthAccessTokenScopeAndUserID returns access token scope and user id
+func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (auth_model.AccessTokenScope, int64) {
+	var accessTokenScope auth_model.AccessTokenScope
 	if !setting.OAuth2.Enabled {
-		return 0
+		return accessTokenScope, 0
 	}
 
 	// JWT tokens require a ".", if the token isn't like that, return early
 	if !strings.Contains(accessToken, ".") {
-		return 0
+		return accessTokenScope, 0
 	}
 
 	token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
 	if err != nil {
 		log.Trace("oauth2.ParseToken: %v", err)
-		return 0
+		return accessTokenScope, 0
 	}
 	var grant *auth_model.OAuth2Grant
 	if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
-		return 0
+		return accessTokenScope, 0
 	}
 	if token.Kind != oauth2_provider.KindAccessToken {
-		return 0
+		return accessTokenScope, 0
 	}
 	if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
-		return 0
+		return accessTokenScope, 0
 	}
-	return grant.UserID
+	accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope)
+	return accessTokenScope, grant.UserID
 }
 
 // CheckTaskIsRunning verifies that the TaskID corresponds to a running task
@@ -120,10 +122,10 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat
 		}
 
 		// Otherwise, check if this is an OAuth access token
-		uid := CheckOAuthAccessToken(ctx, tokenSHA)
+		accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
 		if uid != 0 {
 			store.GetData()["IsApiToken"] = true
-			store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
+			store.GetData()["ApiTokenScope"] = accessTokenScope
 		}
 		return uid
 	}
diff --git a/services/oauth2_provider/access_token.go b/services/oauth2_provider/access_token.go
index dd3f24eeef..d94e15d5f2 100644
--- a/services/oauth2_provider/access_token.go
+++ b/services/oauth2_provider/access_token.go
@@ -6,6 +6,8 @@ package oauth2_provider //nolint
 import (
 	"context"
 	"fmt"
+	"slices"
+	"strings"
 
 	auth "code.gitea.io/gitea/models/auth"
 	"code.gitea.io/gitea/models/db"
@@ -69,6 +71,32 @@ type AccessTokenResponse struct {
 	IDToken      string    `json:"id_token,omitempty"`
 }
 
+// GrantAdditionalScopes returns valid scopes coming from grant
+func GrantAdditionalScopes(grantScopes string) auth.AccessTokenScope {
+	// scopes_supported from templates/user/auth/oidc_wellknown.tmpl
+	scopesSupported := []string{
+		"openid",
+		"profile",
+		"email",
+		"groups",
+	}
+
+	var tokenScopes []string
+	for _, tokenScope := range strings.Split(grantScopes, " ") {
+		if slices.Index(scopesSupported, tokenScope) == -1 {
+			tokenScopes = append(tokenScopes, tokenScope)
+		}
+	}
+
+	// since version 1.22, access tokens grant full access to the API
+	// with this access is reduced only if additional scopes are provided
+	accessTokenScope := auth.AccessTokenScope(strings.Join(tokenScopes, ","))
+	if accessTokenWithAdditionalScopes, err := accessTokenScope.Normalize(); err == nil && len(tokenScopes) > 0 {
+		return accessTokenWithAdditionalScopes
+	}
+	return auth.AccessTokenScopeAll
+}
+
 func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
 	if setting.OAuth2.InvalidateRefreshTokens {
 		if err := grant.IncreaseCounter(ctx); err != nil {
@@ -161,7 +189,13 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
 			idToken.EmailVerified = user.IsActive
 		}
 		if grant.ScopeContains("groups") {
-			groups, err := GetOAuthGroupsForUser(ctx, user)
+			accessTokenScope := GrantAdditionalScopes(grant.Scope)
+
+			// since version 1.22 does not verify if groups should be public-only,
+			// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
+			onlyPublicGroups, _ := accessTokenScope.PublicOnly()
+
+			groups, err := GetOAuthGroupsForUser(ctx, user, onlyPublicGroups)
 			if err != nil {
 				log.Error("Error getting groups: %v", err)
 				return nil, &AccessTokenError{
@@ -192,10 +226,10 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
 
 // returns a list of "org" and "org:team" strings,
 // that the given user is a part of.
-func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User) ([]string, error) {
+func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) {
 	orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
 		UserID:         user.ID,
-		IncludePrivate: true,
+		IncludePrivate: !onlyPublicGroups,
 	})
 	if err != nil {
 		return nil, fmt.Errorf("GetUserOrgList: %w", err)
diff --git a/services/oauth2_provider/additional_scopes_test.go b/services/oauth2_provider/additional_scopes_test.go
new file mode 100644
index 0000000000..d239229f4b
--- /dev/null
+++ b/services/oauth2_provider/additional_scopes_test.go
@@ -0,0 +1,35 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2_provider //nolint
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGrantAdditionalScopes(t *testing.T) {
+	tests := []struct {
+		grantScopes    string
+		expectedScopes string
+	}{
+		{"openid profile email", "all"},
+		{"openid profile email groups", "all"},
+		{"openid profile email all", "all"},
+		{"openid profile email read:user all", "all"},
+		{"openid profile email groups read:user", "read:user"},
+		{"read:user read:repository", "read:repository,read:user"},
+		{"read:user write:issue public-only", "public-only,write:issue,read:user"},
+		{"openid profile email read:user", "read:user"},
+		{"read:invalid_scope", "all"},
+		{"read:invalid_scope,write:scope_invalid,just-plain-wrong", "all"},
+	}
+
+	for _, test := range tests {
+		t.Run(test.grantScopes, func(t *testing.T) {
+			result := GrantAdditionalScopes(test.grantScopes)
+			assert.Equal(t, test.expectedScopes, string(result))
+		})
+	}
+}
diff --git a/templates/user/auth/grant.tmpl b/templates/user/auth/grant.tmpl
index a18a3bd27a..4031dd7a63 100644
--- a/templates/user/auth/grant.tmpl
+++ b/templates/user/auth/grant.tmpl
@@ -8,8 +8,11 @@
 			<div class="ui attached segment">
 				{{template "base/alert" .}}
 				<p>
+					{{if not .AdditionalScopes}}
 					<b>{{ctx.Locale.Tr "auth.authorize_application_description"}}</b><br>
-					{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}
+					{{end}}
+					{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}<br>
+					{{ctx.Locale.Tr "auth.authorize_application_with_scopes" (HTMLFormat "<b>%s</b>" .Scope)}}
 				</p>
 			</div>
 			<div class="ui attached segment">
diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go
index b32d365b04..feb262b50e 100644
--- a/tests/integration/oauth_test.go
+++ b/tests/integration/oauth_test.go
@@ -5,16 +5,25 @@ package integration
 
 import (
 	"bytes"
+	"encoding/base64"
+	"fmt"
 	"io"
 	"net/http"
+	"strings"
 	"testing"
 
+	auth_model "code.gitea.io/gitea/models/auth"
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/json"
 	"code.gitea.io/gitea/modules/setting"
+	api "code.gitea.io/gitea/modules/structs"
 	oauth2_provider "code.gitea.io/gitea/services/oauth2_provider"
 	"code.gitea.io/gitea/tests"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestAuthorizeNoClientID(t *testing.T) {
@@ -477,3 +486,424 @@ func TestOAuthIntrospection(t *testing.T) {
 	resp = MakeRequest(t, req, http.StatusUnauthorized)
 	assert.Contains(t, resp.Body.String(), "no valid authorization")
 }
+
+func TestOAuth_GrantScopesReadUserFailRepos(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	appBody := api.CreateOAuth2ApplicationOptions{
+		Name: "oauth-provider-scopes-test",
+		RedirectURIs: []string{
+			"a",
+		},
+		ConfidentialClient: true,
+	}
+
+	req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+		AddBasicAuth(user.Name)
+	resp := MakeRequest(t, req, http.StatusCreated)
+
+	var app *api.OAuth2Application
+	DecodeJSON(t, resp, &app)
+
+	grant := &auth_model.OAuth2Grant{
+		ApplicationID: app.ID,
+		UserID:        user.ID,
+		Scope:         "openid read:user",
+	}
+
+	err := db.Insert(db.DefaultContext, grant)
+	require.NoError(t, err)
+
+	assert.Contains(t, grant.Scope, "openid read:user")
+
+	ctx := loginUser(t, user.Name)
+
+	authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+	authorizeReq := NewRequest(t, "GET", authorizeURL)
+	authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+	authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&amp")[0]
+
+	accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+		"grant_type":    "authorization_code",
+		"client_id":     app.ClientID,
+		"client_secret": app.ClientSecret,
+		"redirect_uri":  "a",
+		"code":          authcode,
+	})
+	accessTokenResp := ctx.MakeRequest(t, accessTokenReq, 200)
+	type response struct {
+		AccessToken  string `json:"access_token"`
+		TokenType    string `json:"token_type"`
+		ExpiresIn    int64  `json:"expires_in"`
+		RefreshToken string `json:"refresh_token"`
+	}
+	parsed := new(response)
+
+	require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+	userReq := NewRequest(t, "GET", "/api/v1/user")
+	userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+	userResp := MakeRequest(t, userReq, http.StatusOK)
+
+	type userResponse struct {
+		Login string `json:"login"`
+		Email string `json:"email"`
+	}
+
+	userParsed := new(userResponse)
+	require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), userParsed))
+	assert.Contains(t, userParsed.Email, "user2@example.com")
+
+	errorReq := NewRequest(t, "GET", "/api/v1/users/user2/repos")
+	errorReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+	errorResp := MakeRequest(t, errorReq, http.StatusForbidden)
+
+	type errorResponse struct {
+		Message string `json:"message"`
+	}
+
+	errorParsed := new(errorResponse)
+	require.NoError(t, json.Unmarshal(errorResp.Body.Bytes(), errorParsed))
+	assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s): [read:repository]")
+}
+
+func TestOAuth_GrantScopesReadRepositoryFailOrganization(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	appBody := api.CreateOAuth2ApplicationOptions{
+		Name: "oauth-provider-scopes-test",
+		RedirectURIs: []string{
+			"a",
+		},
+		ConfidentialClient: true,
+	}
+
+	req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+		AddBasicAuth(user.Name)
+	resp := MakeRequest(t, req, http.StatusCreated)
+
+	var app *api.OAuth2Application
+	DecodeJSON(t, resp, &app)
+
+	grant := &auth_model.OAuth2Grant{
+		ApplicationID: app.ID,
+		UserID:        user.ID,
+		Scope:         "openid read:user read:repository",
+	}
+
+	err := db.Insert(db.DefaultContext, grant)
+	require.NoError(t, err)
+
+	assert.Contains(t, grant.Scope, "openid read:user read:repository")
+
+	ctx := loginUser(t, user.Name)
+
+	authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+	authorizeReq := NewRequest(t, "GET", authorizeURL)
+	authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+	authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&amp")[0]
+	accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+		"grant_type":    "authorization_code",
+		"client_id":     app.ClientID,
+		"client_secret": app.ClientSecret,
+		"redirect_uri":  "a",
+		"code":          authcode,
+	})
+	accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
+	type response struct {
+		AccessToken  string `json:"access_token"`
+		TokenType    string `json:"token_type"`
+		ExpiresIn    int64  `json:"expires_in"`
+		RefreshToken string `json:"refresh_token"`
+	}
+	parsed := new(response)
+
+	require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+	userReq := NewRequest(t, "GET", "/api/v1/users/user2/repos")
+	userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+	userResp := MakeRequest(t, userReq, http.StatusOK)
+
+	type repo struct {
+		FullRepoName string `json:"full_name"`
+		Private      bool   `json:"private"`
+	}
+
+	var reposCaptured []repo
+	require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), &reposCaptured))
+
+	reposExpected := []repo{
+		{
+			FullRepoName: "user2/repo1",
+			Private:      false,
+		},
+		{
+			FullRepoName: "user2/repo2",
+			Private:      true,
+		},
+		{
+			FullRepoName: "user2/repo15",
+			Private:      true,
+		},
+		{
+			FullRepoName: "user2/repo16",
+			Private:      true,
+		},
+		{
+			FullRepoName: "user2/repo20",
+			Private:      true,
+		},
+		{
+			FullRepoName: "user2/utf8",
+			Private:      false,
+		},
+		{
+			FullRepoName: "user2/commits_search_test",
+			Private:      false,
+		},
+		{
+			FullRepoName: "user2/git_hooks_test",
+			Private:      false,
+		},
+		{
+			FullRepoName: "user2/glob",
+			Private:      false,
+		},
+		{
+			FullRepoName: "user2/lfs",
+			Private:      true,
+		},
+		{
+			FullRepoName: "user2/scoped_label",
+			Private:      true,
+		},
+		{
+			FullRepoName: "user2/readme-test",
+			Private:      true,
+		},
+		{
+			FullRepoName: "user2/repo-release",
+			Private:      false,
+		},
+		{
+			FullRepoName: "user2/commitsonpr",
+			Private:      false,
+		},
+		{
+			FullRepoName: "user2/test_commit_revert",
+			Private:      true,
+		},
+	}
+	assert.Equal(t, reposExpected, reposCaptured)
+
+	errorReq := NewRequest(t, "GET", "/api/v1/users/user2/orgs")
+	errorReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+	errorResp := MakeRequest(t, errorReq, http.StatusForbidden)
+
+	type errorResponse struct {
+		Message string `json:"message"`
+	}
+
+	errorParsed := new(errorResponse)
+	require.NoError(t, json.Unmarshal(errorResp.Body.Bytes(), errorParsed))
+	assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s): [read:user read:organization]")
+}
+
+func TestOAuth_GrantScopesClaimPublicOnlyGroups(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+
+	appBody := api.CreateOAuth2ApplicationOptions{
+		Name: "oauth-provider-scopes-test",
+		RedirectURIs: []string{
+			"a",
+		},
+		ConfidentialClient: true,
+	}
+
+	appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+		AddBasicAuth(user.Name)
+	appResp := MakeRequest(t, appReq, http.StatusCreated)
+
+	var app *api.OAuth2Application
+	DecodeJSON(t, appResp, &app)
+
+	grant := &auth_model.OAuth2Grant{
+		ApplicationID: app.ID,
+		UserID:        user.ID,
+		Scope:         "openid groups read:user public-only",
+	}
+
+	err := db.Insert(db.DefaultContext, grant)
+	require.NoError(t, err)
+
+	assert.ElementsMatch(t, []string{"openid", "groups", "read:user", "public-only"}, strings.Split(grant.Scope, " "))
+
+	ctx := loginUser(t, user.Name)
+
+	authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+	authorizeReq := NewRequest(t, "GET", authorizeURL)
+	authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+	authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&amp")[0]
+
+	accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+		"grant_type":    "authorization_code",
+		"client_id":     app.ClientID,
+		"client_secret": app.ClientSecret,
+		"redirect_uri":  "a",
+		"code":          authcode,
+	})
+	accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
+	type response struct {
+		AccessToken  string `json:"access_token"`
+		TokenType    string `json:"token_type"`
+		ExpiresIn    int64  `json:"expires_in"`
+		RefreshToken string `json:"refresh_token"`
+		IDToken      string `json:"id_token,omitempty"`
+	}
+	parsed := new(response)
+	require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+	parts := strings.Split(parsed.IDToken, ".")
+
+	payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
+	type IDTokenClaims struct {
+		Groups []string `json:"groups"`
+	}
+
+	claims := new(IDTokenClaims)
+	require.NoError(t, json.Unmarshal(payload, claims))
+
+	userinfoReq := NewRequest(t, "GET", "/login/oauth/userinfo")
+	userinfoReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+	userinfoResp := MakeRequest(t, userinfoReq, http.StatusOK)
+
+	type userinfoResponse struct {
+		Login  string   `json:"login"`
+		Email  string   `json:"email"`
+		Groups []string `json:"groups"`
+	}
+
+	userinfoParsed := new(userinfoResponse)
+	require.NoError(t, json.Unmarshal(userinfoResp.Body.Bytes(), userinfoParsed))
+	assert.Contains(t, userinfoParsed.Email, "user2@example.com")
+
+	// test both id_token and call to /login/oauth/userinfo
+	for _, publicGroup := range []string{
+		"org17",
+		"org17:test_team",
+		"org3",
+		"org3:owners",
+		"org3:team1",
+		"org3:teamcreaterepo",
+	} {
+		assert.Contains(t, claims.Groups, publicGroup)
+		assert.Contains(t, userinfoParsed.Groups, publicGroup)
+	}
+	for _, privateGroup := range []string{
+		"private_org35",
+		"private_org35_team24",
+	} {
+		assert.NotContains(t, claims.Groups, privateGroup)
+		assert.NotContains(t, userinfoParsed.Groups, privateGroup)
+	}
+}
+
+func TestOAuth_GrantScopesClaimAllGroups(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+
+	appBody := api.CreateOAuth2ApplicationOptions{
+		Name: "oauth-provider-scopes-test",
+		RedirectURIs: []string{
+			"a",
+		},
+		ConfidentialClient: true,
+	}
+
+	appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+		AddBasicAuth(user.Name)
+	appResp := MakeRequest(t, appReq, http.StatusCreated)
+
+	var app *api.OAuth2Application
+	DecodeJSON(t, appResp, &app)
+
+	grant := &auth_model.OAuth2Grant{
+		ApplicationID: app.ID,
+		UserID:        user.ID,
+		Scope:         "openid groups",
+	}
+
+	err := db.Insert(db.DefaultContext, grant)
+	require.NoError(t, err)
+
+	assert.ElementsMatch(t, []string{"openid", "groups"}, strings.Split(grant.Scope, " "))
+
+	ctx := loginUser(t, user.Name)
+
+	authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+	authorizeReq := NewRequest(t, "GET", authorizeURL)
+	authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+	authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&amp")[0]
+
+	accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+		"grant_type":    "authorization_code",
+		"client_id":     app.ClientID,
+		"client_secret": app.ClientSecret,
+		"redirect_uri":  "a",
+		"code":          authcode,
+	})
+	accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
+	type response struct {
+		AccessToken  string `json:"access_token"`
+		TokenType    string `json:"token_type"`
+		ExpiresIn    int64  `json:"expires_in"`
+		RefreshToken string `json:"refresh_token"`
+		IDToken      string `json:"id_token,omitempty"`
+	}
+	parsed := new(response)
+	require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+	parts := strings.Split(parsed.IDToken, ".")
+
+	payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
+	type IDTokenClaims struct {
+		Groups []string `json:"groups"`
+	}
+
+	claims := new(IDTokenClaims)
+	require.NoError(t, json.Unmarshal(payload, claims))
+
+	userinfoReq := NewRequest(t, "GET", "/login/oauth/userinfo")
+	userinfoReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+	userinfoResp := MakeRequest(t, userinfoReq, http.StatusOK)
+
+	type userinfoResponse struct {
+		Login  string   `json:"login"`
+		Email  string   `json:"email"`
+		Groups []string `json:"groups"`
+	}
+
+	userinfoParsed := new(userinfoResponse)
+	require.NoError(t, json.Unmarshal(userinfoResp.Body.Bytes(), userinfoParsed))
+	assert.Contains(t, userinfoParsed.Email, "user2@example.com")
+
+	// test both id_token and call to /login/oauth/userinfo
+	for _, group := range []string{
+		"org17",
+		"org17:test_team",
+		"org3",
+		"org3:owners",
+		"org3:team1",
+		"org3:teamcreaterepo",
+		"private_org35",
+		"private_org35:team24",
+	} {
+		assert.Contains(t, claims.Groups, group)
+		assert.Contains(t, userinfoParsed.Groups, group)
+	}
+}