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:
| Scope | What it grants |
|---|---|
openid | Required for OIDC. Triggers an id_token in the response. |
profile | Adds given_name, family_name, preferred_username, picture to the ID token and /userinfo. |
email | Adds email, email_verified. |
offline_access | Returns 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_accessThree 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
descriptionis what appears there. - The access token’s
scopeclaim 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.
Consent — the user’s view
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
descriptionyou registered. - An “Approve” button and a “Deny” button.
- Footer links to your
termsUrlandprivacyUrl.
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.
When consent is shown
| Situation | Consent shown? |
|---|---|
| First time user signs in to App | yes |
| User signs in again, same scopes | no |
| User signs in, App requests new scope | yes (just for the new scope) |
| User signs in via different provider, same scopes | no |
| User signs in after explicit revoke | yes |
prompt=consent in /oauth2/authorize | yes (force) |
prompt=none and consent exists | no |
prompt=none and consent missing | error=consent_required |
Consent records
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.
Consent on the device flow
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.
How your app interacts with consent
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:
| Value | Effect |
|---|---|
| (omitted) | Default behavior: only prompt for sign-in if no session, only prompt for consent if not previously granted. |
none | Never prompt. If sign-in is needed, error with login_required. If consent is needed, error with consent_required. Use for silent token renewal. |
login | Force sign-in, even if there’s a session. Use after a security-sensitive event. |
consent | Force consent, even if previously granted. Use to re-confirm. |
select_account | Show 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:
- One thing per scope.
wallets:readis good.wallets(which conflates read and write) is bad. - Action verbs.
marketplace:createis good.marketplace(which makes the user guess what you’ll do) is bad. - 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
scopeclaim; your app enforces against your route permissions.