<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Nicolas Lecocq</title>
    <description>The latest articles on Forem by Nicolas Lecocq (@nicolasai).</description>
    <link>https://forem.com/nicolasai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3916505%2F2e7a8ea4-ac05-4bb1-8758-e63b74054bdf.webp</url>
      <title>Forem: Nicolas Lecocq</title>
      <link>https://forem.com/nicolasai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/nicolasai"/>
    <language>en</language>
    <item>
      <title>Sign In With LinkedIn Using OpenID Connect in Next.js 16</title>
      <dc:creator>Nicolas Lecocq</dc:creator>
      <pubDate>Wed, 06 May 2026 19:24:12 +0000</pubDate>
      <link>https://forem.com/nicolasai/sign-in-with-linkedin-using-openid-connect-in-nextjs-16-1p48</link>
      <guid>https://forem.com/nicolasai/sign-in-with-linkedin-using-openid-connect-in-nextjs-16-1p48</guid>
      <description>&lt;p&gt;LinkedIn finally moved Sign In to OpenID Connect a while back. Most of the tutorials still floating around the internet show the legacy v1 OAuth dance with &lt;code&gt;r_liteprofile&lt;/code&gt; and &lt;code&gt;r_emailaddress&lt;/code&gt; scopes. Those are deprecated. If you copy them, your callback will work for a while and then mysteriously stop, which is the worst kind of bug.&lt;/p&gt;

&lt;p&gt;Here is the flow that actually works in 2026 with Next.js 16 and NextAuth v5.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scopes you want
&lt;/h2&gt;

&lt;p&gt;Forget &lt;code&gt;r_liteprofile&lt;/code&gt; and &lt;code&gt;r_emailaddress&lt;/code&gt;. The current Sign-In product asks for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openid profile email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three scopes, lowercase, space separated. That is the entire request. LinkedIn returns a standard OIDC ID token plus a regular access token. No more profile-specific endpoints, no more email-specific endpoints. The user identity ships in the token claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  The provider config in NextAuth v5
&lt;/h2&gt;

&lt;p&gt;NextAuth v5 has a built-in LinkedIn provider, but it ships with the legacy scopes. You need to override it. Here is the working config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/auth.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;LinkedIn&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-auth/providers/linkedin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signOut&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NextAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;LinkedIn&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LINKEDIN_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;clientSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LINKEDIN_CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.linkedin.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openid profile email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.linkedin.com/oauth/v2/accessToken&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;userinfo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.linkedin.com/v2/userinfo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;picture&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two parts that trip people up: the &lt;code&gt;userinfo&lt;/code&gt; endpoint is &lt;code&gt;/v2/userinfo&lt;/code&gt;, not the legacy &lt;code&gt;/v2/me&lt;/code&gt;, and the &lt;code&gt;profile.sub&lt;/code&gt; field is what you want as the stable LinkedIn user ID. Do not use &lt;code&gt;profile.id&lt;/code&gt;. It does not exist on the new endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Callback URL
&lt;/h2&gt;

&lt;p&gt;In the LinkedIn Developer Portal, register exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://your-domain.com/api/auth/callback/linkedin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you forget the trailing path, LinkedIn returns a generic "Bummer, something went wrong" page with no useful debug info, and you will spend forty minutes wondering if you broke your environment variables. Ask me how I know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storing the access token
&lt;/h2&gt;

&lt;p&gt;The OIDC ID token tells you who the user is. The access token lets you call other LinkedIn APIs on their behalf, like posting to their feed. NextAuth v5 hands you both in the &lt;code&gt;jwt&lt;/code&gt; callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;linkedin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;linkedinAccessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;linkedinExpiresAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist &lt;code&gt;linkedinAccessToken&lt;/code&gt; to your database row for the user, plus the expiry timestamp. LinkedIn's access tokens are valid for 60 days. They do support refresh tokens now, but only if you specifically request the &lt;code&gt;offline_access&lt;/code&gt; scope, which most apps do not need. For a 60-day window, store it, check the expiry on each use, and re-prompt if expired.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you do not get from OIDC
&lt;/h2&gt;

&lt;p&gt;Sign-In gives you identity. It does not give you posting rights. To post on someone's behalf, you need the separate &lt;code&gt;w_member_social&lt;/code&gt; scope through the "Share on LinkedIn" product. Same OAuth, different consent screen, different scope. Apply for it through the developer portal.&lt;/p&gt;

&lt;p&gt;Same story for company page management, feed reading, and the Community Management API. Each is a separate "product" you have to apply for in your LinkedIn app, and each has its own approval queue. Sign-In gets approved instantly. Posting takes a few days. Community APIs take longer and ask for use cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug to watch for
&lt;/h2&gt;

&lt;p&gt;LinkedIn caches the consent screen aggressively. If you change scopes in your code and try to test, the user gets sent back to LinkedIn with the OLD consent already granted. The token they return does not include your new scopes. The fix: revoke the existing connection in their LinkedIn account settings, or pass &lt;code&gt;prompt=consent&lt;/code&gt; in the authorization params:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openid profile email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;consent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This forces a fresh consent dialog every time. Use it during development, drop it for production.&lt;/p&gt;

&lt;p&gt;That is the whole flow. Three scopes, one userinfo endpoint, one callback URL, one access token that lives 60 days. Most of the painful parts of the old LinkedIn OAuth went away when they moved to OIDC. The rest of the painful parts are documented above so you do not have to discover them at 2am.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>oauth</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
