Skip to Content
ConceptsScopes and consent

Scopes and consent

What an app is allowed to ask for, and what the user actually approves.

Standard OIDC scopes

Every Synq app can request these without any extra setup:

ScopeWhat it grants
openidRequired for OIDC. Triggers an id_token in the response.
profileAdds given_name, family_name, preferred_username, picture to the ID token and /userinfo.
emailAdds email, email_verified.
offline_accessReturns a refresh_token. Without it, the user re-authenticates every hour.

Request them in the scope query parameter at /oauth2/authorize, space-separated:

scope=openid+profile+email+offline_access

Three of those should be in every request: openid (it’s not optional for OIDC), profile (you almost certainly want a name to render), and offline_access (you almost certainly want refresh). email is optional but most products need it.

Brand-defined scopes

On top of the OIDC standard ones, a Brand can declare its own scope vocabulary. Each scope is a name and a description; apps under the brand request them the same way they request standard scopes.

interface BrandScope { brand: string name: string // e.g. 'wallets:read' description: string // shown on consent screen, plain language }

Brand scopes are not OIDC standard, so they only have meaning inside your own backends. They are tags Synq enforces:

  • An App can request only scopes registered on its Brand (or the OIDC standard ones).
  • The user sees a consent screen the first time an App asks for scopes they haven’t granted; the description is what appears there.
  • The access token’s scope claim contains whatever the user granted (intersection of requested + allowed + previously consented).

Common patterns:

  • Read/write split: wallets:read, wallets:write.
  • Resource-action: marketplace:create, marketplace:delete.
  • Subject: user:profile, user:billing, org:members.

The convention is <resource>:<action> or <scope>:<modifier>. Two to four colon-separated parts is the sweet spot.

The first time an App asks a given user for a given set of scopes, Synq shows a consent screen:

  • The Brand’s name and logo at the top.
  • The Brand’s description (if set).
  • One row per requested scope, showing the description you registered.
  • An “Approve” button and a “Deny” button.
  • Footer links to your termsUrl and privacyUrl.

Subsequent requests from the same user to the same App for scopes they have already granted skip the consent screen — until the user or app revokes the grant.

SituationConsent shown?
First time user signs in to Appyes
User signs in again, same scopesno
User signs in, App requests new scopeyes (just for the new scope)
User signs in via different provider, same scopesno
User signs in after explicit revokeyes
prompt=consent in /oauth2/authorizeyes (force)
prompt=none and consent existsno
prompt=none and consent missingerror=consent_required

Each approval is stored as a ConsentGrant:

interface ConsentGrant { id: string user: string // user id app: string // App's client_id scopes: string[] // granted scopes grantedAt: string revokedAt?: string }

A user can view all their grants in the user dashboard, where they can revoke any of them. Apps can also revoke their own grants programmatically.

For urn:ietf:params:oauth:grant-type:device_code, the consent surface is the device verification page (/device?user_code=ABCD-1234). The user enters the code from their CLI or agent, signs in if not already, and approves the grant on the same page.

The approve/deny endpoints (/device/approve, /device/deny) are called by Synq’s own page, not your app.

For /oauth2/authorize, you do not need to do anything special. Synq’s UI handles the consent surface. Your app receives either the auth code (on approval) or error=access_denied (on denial).

To revoke a consent grant from your app:

// Revoke a single user's grant for this app await fetch(`https://synq.id/users/${userId}/consent-grants/${appId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${m2mToken}` }, })

To list a user’s consent grants from your app (so you can show them which other apps in your ecosystem they have authorized):

const r = await fetch(`https://synq.id/users/${userId}/consent-grants`, { headers: { Authorization: `Bearer ${m2mToken}` }, }) const grants = await r.json()

prompt parameter — fine control

Synq honors the OIDC prompt parameter on /oauth2/authorize:

ValueEffect
(omitted)Default behavior: only prompt for sign-in if no session, only prompt for consent if not previously granted.
noneNever prompt. If sign-in is needed, error with login_required. If consent is needed, error with consent_required. Use for silent token renewal.
loginForce sign-in, even if there’s a session. Use after a security-sensitive event.
consentForce consent, even if previously granted. Use to re-confirm.
select_accountShow account chooser if multiple accounts are signed in.

Combine them with spaces: prompt=login consent.

Defining good scopes

The user reads your scope descriptions on the consent screen. Three rules of thumb:

  1. One thing per scope. wallets:read is good. wallets (which conflates read and write) is bad.
  2. Action verbs. marketplace:create is good. marketplace (which makes the user guess what you’ll do) is bad.
  3. The description is for the user. Plain language. “Read your linked wallet addresses” is better than “List the user’s userPublicKeys”.

Granular scopes give users control and give your team a clean authorization story in your code. Coarse scopes are fast to ship and create grief later.

In your backend code

Once you’ve verified the access token (see Sessions and tokens), check scopes on the decoded payload:

function requireScope(scope: string) { return (req, res, next) => { const granted = (req.user.scope ?? '').split(' ') if (!granted.includes(scope)) { return res.status(403).json({ error: 'forbidden', missing: scope }) } next() } } app.get('/api/wallets', requireScope('wallets:read'), handler)

Mental model summary

  • OIDC scopes (openid, profile, email, offline_access) are universal and don’t need brand setup.
  • Brand scopes are vocabulary you define for your own backends to enforce. Synq enforces “this scope exists on this brand” and surfaces them on the consent screen.
  • Consent is sticky. Granted once, reused forever, until revoked.
  • prompt= is the lever for the rare cases you need to force or skip prompts.
  • Check scopes server-side. Synq tells you what was granted in the token’s scope claim; your app enforces against your route permissions.
Last updated on