Fill your app's profile from NobleID

When a person signs in with NobleID and agrees, your app can fill its own profile from theirs: name, photo, ORCID iD, institution, role and more. It works the same for journals, repositories, lab sites and any other app.

1. Ask for the data

  1. In Dashboard → OAuth apps, edit your app and tick the scopes you will use. No review is needed. A new app has only OpenID and Profile.
  2. Ask for the same scopes in your sign-in request. Asking for one your app does not have stops sign-in with invalid_scope.
  3. Each person sees every piece of data on the consent screen and can untick any of it. Read the scope in the token response to see what they shared.
ScopeWhat it fills
Profile profileName, username, photo, website and research interests
Email emailEmail address, and whether the person confirmed it
ORCID iD orcidORCID iD, when the person proved it by signing in to ORCID
Affiliations affiliationsMain institution with its ROR iD, and each role and start year
Works worksThe works the person registered on NobleID

2. Map the claims to your fields

Some claims are only in the userinfo response, not in the ID token. Call https://id.nobleid.org/oauth/userinfo with the access token, and check that its sub is the ID token's.

ClaimTypeScopeInFills
substringopenidID token, userinfoAccount key (not shown)Key the account on it. It never changes.
nobleidstringopenidID token, userinfoNobleID
nobleid_uriURLopenidID token, userinfoLink to NobleIDShow it next to the person's name.
namestringprofileID token, userinfoDisplay name
given_namestringprofileID token, userinfoFirst name
family_namestringprofileID token, userinfoLast name
preferred_usernamestringprofileID token, userinfoSuggested usernameIt can change. Never key an account on it.
picturehttps URLprofileID token, userinfoPhotoCopy the image if you keep it. The URL changes when the person changes their photo.
websitehttp(s) URLprofileID token, userinfoWebsite
profileURLprofileID token, userinfoLink to NobleID profileAbsent when the person's profile is not public.
research_interestsstring[]profileUserinfoResearch interests, keywords
emailstringemailID token, userinfoEmail
email_verifiedbooleanemailID token, userinfo(check)Link to an existing account by email only when this is true.
orcidURLorcidID token, userinfoORCID iDhttps://orcid.org/… Sent only when the person proved the iD by signing in to ORCID.
affiliation{ name, ror? }affiliationsID token, userinfoInstitution, affiliationThe main one. ror is an https://ror.org/… URL. Entered by the person, not checked by NobleID.
affiliations{ organization, role?, start_year?, ror? }[]affiliationsUserinfoPosition, role, affiliation historyThe main affiliation comes first. start_year is a number.
works_endpointURLworksUserinfoPublications listGET it with the access token: titles, ARKs, DOIs and dates.
  • A claim with no value is left out. It is never null or an empty string. A missing claim means "not shared or not known", never false.
  • Text is trimmed. URLs are absolute, and picture is always https.
  • Affiliations are what the person entered. NobleID does not check them. An ORCID iD is sent only after the person proved it.
  • NobleID does not send a phone number, postal address, country or bio.

3. Fill only what is empty

Never overwrite what the person edited in your app. Their own edits win over NobleID.

  • On the first sign-in, fill the fields that are empty.
  • On each later sign-in, read the claims again. Update a field only if it still holds what NobleID sent last time. Keep that last value next to the account to compare.
  • Never clear a field because a claim is missing. The person may have unticked that scope.
  • Key the account on sub, never on email, username or name.

Code

Two functions: one turns the claims into a plain profile, the other decides which fields to change.

profile.ts
// After your callback verified the ID token (idToken) and read userinfo.
type Claims = Record<string, unknown>
const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined)

export function profileFromClaims(idToken: Claims, userinfo: Claims = {}) {
  if (userinfo.sub !== undefined && userinfo.sub !== idToken.sub) throw new Error("userinfo is for another person")
  const c = { ...idToken, ...userinfo } // userinfo is fresher and has more
  const main = (c.affiliation ?? {}) as { name?: string; ror?: string }
  const list = Array.isArray(c.affiliations) ? (c.affiliations as { role?: string }[]) : []
  return {
    sub: String(c.sub),
    nobleid: text(c.nobleid),
    name: text(c.name),
    givenName: text(c.given_name),
    familyName: text(c.family_name),
    picture: text(c.picture)?.startsWith("https://") ? text(c.picture) : undefined,
    website: text(c.website),
    researchInterests: Array.isArray(c.research_interests) ? (c.research_interests as string[]) : undefined,
    email: text(c.email),
    emailVerified: c.email_verified === true,
    orcid: c.orcid_verified === true ? text(c.orcid) : undefined,
    institution: text(main.name),
    institutionRor: text(main.ror),
    role: text(list[0]?.role), // the main affiliation comes first
  }
}

// Fill a field when it is empty, or when it still holds what NobleID sent
// last time (the person has not edited it here). Never clear a field
// because a claim is missing: the person may have unticked that scope.
export function fillProfile<T extends Record<string, unknown>>(current: T, fresh: Partial<T>, last: Partial<T> = {}) {
  const changes: Partial<T> = {}
  const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
  for (const key of Object.keys(fresh) as (keyof T)[]) {
    const value = fresh[key]
    if (value === undefined) continue
    const now = current[key]
    const empty = now == null || now === "" || (Array.isArray(now) && now.length === 0)
    if (empty || (last[key] !== undefined && same(now, last[key]))) changes[key] = value
  }
  return changes
}

// On every sign-in:
const p = profileFromClaims(idToken, userinfo)
const fromNobleId = { displayName: p.name, orcid: p.orcid, institution: p.institution, website: p.website }
const changes = fillProfile(user.profile, fromNobleId, user.lastFromNobleId)
await saveProfile(user.id, changes)
await saveLastFromNobleId(user.id, fromNobleId) // compare against it next time

The sign-in itself (PKCE, the ID token check, userinfo) is in Get started.

On a research platform

Each guide lists the fields that platform fills from NobleID.

    ByNobleID