OpenID ConnectAuthorization code + PKCE

Sign in with NobleID

Let researchers sign in to your journal, repository, conference system or app with their NobleID — and, with their permission, receive their verified profile, ORCID iD, affiliations and registered works.

What you get

A standard OpenID Connect provider. Any OIDC library works; each piece of data is a separate scope the person can see and untick on the consent screen.

  • Sign-in

    A stable account identifier (sub) and the person’s NobleID, with no password for you to store.

  • Profile

    Name, username, photo, website and research interests.

  • Verification flags

    Whether the person passed NobleID’s identity check, and whether NobleID staff have marked them a verified researcher.

  • ORCID iD

    The iD on their NobleID profile, flagged verified only when it was connected through ORCID’s own sign-in.

  • Affiliations

    Organisation, role and start year as entered by the person.

  • Works

    The works they registered on NobleID, each with its ARK, DOI where there is one, and version.

Everything is read-only. No scope lets an app change a NobleID account or register works on someone’s behalf. (Partner minting, works:write, is planned for a partner programme and is not available.)

Quick start

  1. Register your app in Dashboard → OAuth apps. Choose confidential if you have a server, public for a browser-only or native app. Add your exact redirect URI(s). Copy the client secret when it is shown — it is shown once. You need two-step login on your NobleID to register an app, edit it (including redirect URIs) or rotate its secret, and it can’t be turned off while you have an active app.
  2. Send people to the authorize endpoint with response_type=code, your client_id and redirect_uri, the scopes you need, a random state and nonce, and a PKCE code_challenge with code_challenge_method=S256.
  3. On your redirect URI, check state and iss, exchange the code at the token endpoint (form-encoded, with the code_verifier), verify the ID token, and key the account on sub.

Codes are valid for 120 seconds, access tokens for 15 minutes. Ask for offline_access only if you need to call NobleID when the person is not present; refresh tokens last 30 days and are replaced on every use. The consent screen does not pre-tick offline_access or email (unless the person's email is public); it pre-ticks only what is already public on their profile.

Code

Copy-paste starting points. Each one uses PKCE, state and nonce and checks the issuer — do not remove those parts.

App Router, confidential client, two route handlers, jose for ID-token verification. Link your sign-in button to /auth/nobleid/start.

.env.local
# .env.local  (server-only — no NEXT_PUBLIC_ prefix)
NOBLEID_CLIENT_ID=nbl_...
NOBLEID_CLIENT_SECRET=...
NOBLEID_REDIRECT_URI=https://app.example.org/auth/nobleid/callback

# npm install jose
app/auth/nobleid/start/route.ts
// app/auth/nobleid/start/route.ts
import { NextResponse } from "next/server"
import crypto from "node:crypto"

const b64url = (buf: Buffer) => buf.toString("base64url")

export async function GET() {
  const state = b64url(crypto.randomBytes(32))
  const nonce = b64url(crypto.randomBytes(32))
  const verifier = b64url(crypto.randomBytes(32))
  const challenge = b64url(crypto.createHash("sha256").update(verifier).digest())

  const url = new URL("https://www.nobleid.org/oauth/authorize")
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: process.env.NOBLEID_CLIENT_ID!,
    redirect_uri: process.env.NOBLEID_REDIRECT_URI!,
    scope: "openid profile works",
    state,
    nonce,
    code_challenge: challenge,
    code_challenge_method: "S256",
  }).toString()

  const res = NextResponse.redirect(url)
  // Short-lived, httpOnly, scoped to the callback path. SameSite=Lax is sent
  // on the top-level redirect back from NobleID.
  res.cookies.set("nobleid_oidc", JSON.stringify({ state, nonce, verifier }), {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/auth/nobleid",
    maxAge: 600,
  })
  return res
}
app/auth/nobleid/callback/route.ts
// app/auth/nobleid/callback/route.ts
import { NextRequest, NextResponse } from "next/server"
import { createRemoteJWKSet, jwtVerify } from "jose"

const ISSUER = "https://id.nobleid.org"
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`))

function fail(msg: string) {
  const res = new NextResponse(`Sign-in failed: ${msg}`, { status: 400 })
  res.cookies.delete({ name: "nobleid_oidc", path: "/auth/nobleid" })
  return res
}

export async function GET(req: NextRequest) {
  const raw = req.cookies.get("nobleid_oidc")?.value
  if (!raw) return fail("session expired, try again")
  const { state, nonce, verifier } = JSON.parse(raw)

  const q = req.nextUrl.searchParams
  if (q.get("error")) return fail(q.get("error_description") ?? q.get("error")!)
  if (q.get("state") !== state) return fail("state mismatch")
  if (q.get("iss") !== ISSUER) return fail("issuer mismatch") // RFC 9207

  // Token exchange: form-encoded, client_secret_basic.
  const id = encodeURIComponent(process.env.NOBLEID_CLIENT_ID!)
  const secret = encodeURIComponent(process.env.NOBLEID_CLIENT_SECRET!)
  const tokenRes = await fetch(`${ISSUER}/oauth/token`, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Basic ${Buffer.from(`${id}:${secret}`).toString("base64")}`,
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: q.get("code") ?? "",
      redirect_uri: process.env.NOBLEID_REDIRECT_URI!,
      code_verifier: verifier,
    }),
    cache: "no-store",
  })
  if (!tokenRes.ok) return fail("token exchange failed")
  const tokens = await tokenRes.json()

  // Verify the ID token: ES256 signature against NobleID's JWKS, iss, aud, exp.
  const { payload } = await jwtVerify(tokens.id_token, JWKS, {
    issuer: ISSUER,
    audience: process.env.NOBLEID_CLIENT_ID!,
    algorithms: ["ES256"],
  })
  if (payload.nonce !== nonce) return fail("nonce mismatch")

  // Optional: claims that are only in userinfo (affiliations, research_interests…)
  const userinfo = await fetch(`${ISSUER}/oauth/userinfo`, {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
    cache: "no-store",
  }).then((r) => r.json())
  if (userinfo.sub !== payload.sub) return fail("userinfo subject mismatch")

  // Key the account on payload.sub (stable). Never link accounts by email
  // unless email_verified is true.
  // const user = await upsertUser({ nobleidSub: payload.sub, nobleid: payload.nobleid, name: userinfo.name })
  // await createYourOwnSession(user)

  const res = NextResponse.redirect(new URL("/", req.url))
  res.cookies.delete({ name: "nobleid_oidc", path: "/auth/nobleid" })
  return res
}

Scopes & claims

openid is required. Ask only for what you use — people see every scope and can untick the optional ones, so read the granted scope in the token response rather than assuming.

ScopeClaims
openidrequired

Your NobleID and a stable account identifier

  • sub— Stable, opaque account identifier (a UUID). Use this as the key for the person in your database. It is not the NobleID number.
  • nobleid— The person’s public NobleID code.
  • nobleid_uri— https://www.nobleid.org/{nobleid} — the link to show next to their name.
profile

Your name, username, photo, website, research interests and verification badges

  • name
  • given_name
  • family_name
  • preferred_username
  • profile
  • picture
  • website
  • research_interests— Userinfo only.
  • identity_verified— true when the person has passed NobleID’s identity check (run by our verification provider, Didit). One overall result, not per-step.
  • nobleid_verified_researcher— true when NobleID staff have granted the verified-researcher badge.
  • updated_at
email

Your email address

  • email
  • email_verified— true only when the person has confirmed this address: they opened the single-use link NobleID emailed to it, or Google or Apple vouched for the same address at sign-in. Changing the address resets it. Do not link accounts by an unverified email.
orcid

The ORCID iD on your profile

  • orcid— Full https://orcid.org/… URI.
  • orcid_verified— true only if the person connected ORCID through ORCID’s own sign-in. Otherwise the iD was typed in and is self-asserted.
affiliations

The organisations and roles listed on your profile

  • affiliations— Userinfo only. [{ organization, role, start_year }] — entered by the person, not checked by NobleID.
works

The list of works you have registered, with their ARK identifiers

  • works_count— Userinfo. Number of works the person has registered.
  • works_endpoint— Userinfo. GET it with the access token: https://id.nobleid.org/oauth/works?limit=&offset=
offline_access

Stay connected when you are not using the app (refresh token, 30 days)

No claims — returns a refresh token (30 days, rotated on every use; reusing an old one revokes the whole chain).

Works items (GET https://id.nobleid.org/oauth/works, Bearer token with scope works): nobleid, ark, url, title, type, doi, publication_date, publication_year, version, registered_at.

Endpoints

Issuerhttps://id.nobleid.org
Discoveryhttps://id.nobleid.org/.well-known/openid-configuration
Authorizationhttps://www.nobleid.org/oauth/authorize
Tokenhttps://id.nobleid.org/oauth/token
Userinfohttps://id.nobleid.org/oauth/userinfo
JWKShttps://id.nobleid.org/.well-known/jwks.json
Revocationhttps://id.nobleid.org/oauth/revoke
Introspectionhttps://id.nobleid.org/oauth/introspect
Pushed authorization (PAR)https://id.nobleid.org/oauth/par
Works list (scope works)https://id.nobleid.org/oauth/works?limit=&offset=
  • The authorization endpoint is the consent page on www.nobleid.org; every other endpoint is under the issuer, id.nobleid.org. Fetch the discovery document rather than hard-coding URLs.
  • Only response_type=code with PKCE S256. No implicit or hybrid flow, no plain PKCE.
  • The token endpoint accepts application/x-www-form-urlencoded only. ID tokens are ES256 JWTs.
  • The authorization response includes iss (RFC 9207). Check it equals the issuer.

Security requirements for your integration

  • Use PKCE with S256 on every request, with a fresh random verifier of at least 32 bytes.
  • Send a random state and check it on return; send a nonce and check it in the ID token.
  • Check iss in the authorization response, and iss, aud (your client ID) and exp in the ID token.
  • Verify the ID token’s ES256 signature against the JWKS on your server. Cache the key set and refetch when you see an unknown kid.
  • Register exact redirect URIs. They are compared character for character; https only, except http on localhost / 127.0.0.1 / [::1], where the port may vary.
  • Keep the client secret on your server. Browser and mobile apps must be public clients.
  • Key accounts on sub, never on email. Never link an existing account by email unless email_verified is true.
  • Refresh tokens rotate: store the new one each time. Presenting an old one revokes the whole family and signs the person out of your app on their next refresh.
  • On sign-out, revoke the refresh token at https://id.nobleid.org/oauth/revoke.

How NobleID protects sign-in

  • Authorization codes are single-use, stored hashed, and expire after 120 seconds.
  • Access tokens last 15 minutes. Refresh tokens rotate on every use, and reuse of an old refresh token revokes every token in that chain.
  • Consent is per app and per scope. People can remove an app at any time in Settings → Connected apps, which revokes its tokens immediately.
  • The consent page cannot be framed by another site, and only redirects to URIs NobleID has validated.
  • Sign-in and consent events are written to an audit log, and the endpoints are rate-limited.
  • Everyone who registers or manages a partner app must have two-step login (an authenticator-app code) on their NobleID account.
  • ID tokens are signed with ES256 by a key held in AWS KMS: the private key never leaves the key service, and the NobleID server can only ask it to sign. Public keys are published at the JWKS URL; when the key is rotated, the old one stays published until every token it signed has expired.

What a NobleID sign-in tells you: the person controls a NobleID account that signs in with email and password, Google or Apple, and, if they have turned it on, also entered a code from an authenticator app (two-step login). Two-step login is optional, so read amr (below) to see how this sign-in was done. identity_verified tells you separately whether the account holder passed an identity check.

How the person signed in (amr)

ID tokens from the authorization-code exchange carry amr (RFC 8176): the methods the person used to sign in to NobleID for this session, alongside auth_time.

ValueMeaning
pwdEmail and password.
googleSign in with Google (Google verified the account; NobleID did not see a password).
appleSign in with Apple.
privyLegacy sign-in (Privy), kept for older accounts.
otpA code from the person’s authenticator app (RFC 6238 TOTP), or one of their single-use backup codes.
mfaTwo methods were used: one of the above plus otp.

Examples: ["pwd"], ["pwd","otp","mfa"], ["google"], ["google","otp","mfa"].

  • To require two-step login, check that amr contains otp and send the person back if it does not; they can turn it on in their NobleID security settings.
  • amr is omitted when NobleID does not know it: ID tokens issued from a refresh token (which also omit auth_time), and sessions that began before two-step login existed.
  • NobleID issues no acr and defines no assurance levels; acr_values is ignored. max_age and prompt=login are honoured against auth_time.
  • Two-step login is a time-based code from an authenticator app, with single-use backup codes. NobleID does not offer passkeys, WebAuthn security keys or SMS codes.

Branding

  • Use the official button (below, or rendered by the SDK). Don’t redraw, recolour or stretch the logo.
  • Wording: “Sign in with NobleID” or “Continue with NobleID”.
  • Minimum height 40px. Keep clear space around the button of at least 8px.
  • Place it with equal prominence next to your other sign-in options.
  • Show a person’s NobleID as the full link, e.g. https://www.nobleid.org/{nobleid} (the nobleid_uri claim).

FAQ

How does this relate to ORCID?
They are complementary. ORCID identifies researchers across the scholarly record; NobleID can carry the person’s ORCID iD (scope orcid, with orcid_verified telling you whether it was connected through ORCID itself), and adds works registered on NobleID with ARK identifiers, plus the identity-check flag. Many integrations will offer both buttons.
What is the difference between sub and nobleid?
sub is an opaque, stable account identifier — use it as your database key. nobleid is the person’s public NobleID code, for display and linking.
Can my app register works or edit a profile?
No. All scopes are read-only. Partner minting (works:write) is planned for a partner programme and is not available today.
Is there single sign-out?
There is no end-session endpoint today. Sign the person out of your own app and revoke their refresh token.
How does my app get the verified badge on the consent screen?
Email info@nobleid.org with your client ID and the domains listed on the app. Staff check domain ownership by hand with a DNS TXT record. Until then people see a “not verified” notice.

Test client

Run a real sign-in against NobleID with your own client ID, and see exactly what your app would receive.

  1. In OAuth apps, register a public client (or edit one) and add this redirect URI exactly:
https://www.nobleid.org/developers/sign-in/callback
  1. Paste its client ID below, pick scopes, and run the flow. You will see the real consent screen.
  2. On return, this page exchanges the code (no secret — public client), shows the decoded ID token and the userinfo response.

Confidential clients cannot be tested here — their code exchange needs the client secret, which must never be typed into a web page. Use your own server, or register a second, public client for testing.

    ByNobleID