Developers / Guides

Verified Ham with Next.js (Auth.js)

A custom OpenID Connect provider for Auth.js, in a dozen lines.

Auth.js (next-auth) takes any OpenID Connect provider as a few lines of configuration. This is for Auth.js v5; the provider object is the same in v4's authOptions.

1. Register your app

  1. Create a Verified Ham account, if you don't have one, and sign in.
  2. Go to Register an app.
  3. Give it a name. People see it when they sign in.
  4. Set the redirect URI to:
    https://your-app.example.com/api/auth/callback/verifiedham
  5. Choose Confidential, and register.
  6. Copy the client ID and client secret. The secret is shown only once.

You can sign in to your own app straight away, to test. Other people can sign in once you've verified your own call sign.

Add http://localhost:3000/api/auth/callback/verifiedham on a second line to sign in from your development machine too.

2. Add the provider

In auth.ts, add Verified Ham as a provider, and carry the call sign into the session. The checks line matters: Verified Ham requires PKCE. Set AUTH_VERIFIEDHAM_ID and AUTH_VERIFIEDHAM_SECRET in .env.local.

// auth.ts: Verified Ham as a provider for Auth.js (next-auth v5).
//
// Set AUTH_SECRET, AUTH_VERIFIEDHAM_ID and AUTH_VERIFIEDHAM_SECRET in .env.local.
// Register the app with the redirect URI
// https://your-app.example.com/api/auth/callback/verifiedham
// (and http://localhost:3000/api/auth/callback/verifiedham for development).

import NextAuth from "next-auth"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    {
      id: "verifiedham",
      name: "Verified Ham",
      type: "oidc",
      issuer: "https://verifiedham.net",
      clientId: process.env.AUTH_VERIFIEDHAM_ID,
      clientSecret: process.env.AUTH_VERIFIEDHAM_SECRET,
      authorization: { params: { scope: "openid callsign" } },
      // Verified Ham requires PKCE.
      checks: ["pkce", "state"],
      profile(profile) {
        return { id: profile.sub, name: profile.callsign ?? null }
      },
    },
  ],
  callbacks: {
    jwt({ token, profile }) {
      if (profile) {
        token.callsign = profile.callsign
        // Trust the call sign only when this is true.
        token.callsignVerified = profile.callsign_verified === true
      }
      return token
    },
    session({ session, token }) {
      return { ...session, callsign: token.callsign, callsignVerified: token.callsignVerified }
    },
  },
})

Use issuer: "https://verifiedham.net" if you're testing against this server.

3. Sign in

import { signIn } from "@/auth"

export function SignIn() {
  return (
    <form action={async () => { "use server"; await signIn("verifiedham") }}>
      <button type="submit">Sign in with Verified Ham</button>
    </form>
  )
}

Then const session = await auth() gives you session.callsign and session.callsignVerified. Use the official button for the sign-in link.