Server: exchange the code

Exchange the authorization code for an id_token on your backend and verify it.

The browser hands your backend a one-time code. Your backend:

  1. POSTs it to the token endpoint with client_secret and the PKCE code_verifier.
  2. Verifies the returned id_token (signature, iss, aud, exp, nonce).
  3. Finds or creates the user by sub and starts its own session.

The code is single-use and lives 60 seconds: exchange it right away and never retry with the same code.

Token request

POST https://paspo.id/api/w/v1/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=…
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&redirect_uri=https://app.example.com/auth/paspo/callback
&code_verifier=…
Parameter
grant_typeauthorization_code
codeFrom onSuccess or the callback query string
client_id, client_secretYour client. HTTP Basic auth (client_secret_basic) works too
redirect_uriExactly the value used in signIn. Omit it when the SDK returned via: "fedcm"
code_verifiertakeVerifier(state) from the browser

Response 200:

{
  "access_token": "…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "id_token": "eyJhbGciOiJSUzI1NiIs…",
  "scope": "openid email"
}

Errors follow RFC 6749: { "error": "invalid_grant", "error_description": "…" }.

Verify the id_token

The id_token is a JWT signed with RS256. Check:

  • the signature against https://paspo.id/api/w/v1/oauth/jwks (cache the key set);
  • iss = https://paspo.id;
  • aud = your client_id;
  • exp is in the future;
  • nonce = the value from takeNonce(state), if you sent one.

Use the claims only after these checks pass. See Reference → Claims.

Node.js (Express)

npm install jose express express-session
server/paspo.ts
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";

const ISSUER = "https://paspo.id";
const TOKEN_URL = "https://paspo.id/api/w/v1/oauth/token";
const JWKS = createRemoteJWKSet(new URL("https://paspo.id/api/w/v1/oauth/jwks"));

const CLIENT_ID = process.env.PASPO_CLIENT_ID!;
const CLIENT_SECRET = process.env.PASPO_CLIENT_SECRET!;
const REDIRECT_URI = process.env.PASPO_REDIRECT_URI!;

export type PaspoClaims = JWTPayload & {
  email?: string;
  email_verified?: boolean;
  phone_number?: string;
  phone_number_verified?: boolean;
  name?: string;
  nonce?: string;
};

export async function exchangePaspoCode(input: {
  code: string;
  code_verifier: string;
  nonce?: string;
  via?: string;
}): Promise<PaspoClaims> {
  const body = new URLSearchParams({
    grant_type: "authorization_code",
    code: input.code,
    code_verifier: input.code_verifier,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  });
  if (input.via !== "fedcm") body.set("redirect_uri", REDIRECT_URI);

  const response = await fetch(TOKEN_URL, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body,
  });
  const tokens = await response.json();
  if (!response.ok) throw new Error(tokens.error ?? "token_request_failed");

  const { payload } = await jwtVerify<PaspoClaims>(tokens.id_token, JWKS, {
    issuer: ISSUER,
    audience: CLIENT_ID,
    algorithms: ["RS256"],
  });
  if (input.nonce && payload.nonce !== input.nonce) throw new Error("nonce_mismatch");
  return payload;
}
server/app.ts
import express from "express";
import session from "express-session";
import { exchangePaspoCode } from "./paspo";

const app = express();
app.use(express.json());
app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false,
  cookie: { httpOnly: true, secure: true, sameSite: "lax" } }));

app.post("/auth/paspo", async (req, res) => {
  const { code, code_verifier, nonce, via } = req.body ?? {};
  if (typeof code !== "string" || typeof code_verifier !== "string") {
    return res.status(400).json({ error: "invalid_request" });
  }
  try {
    const claims = await exchangePaspoCode({ code, code_verifier, nonce, via });
    const user = await upsertUserByPaspoSub(claims.sub!, claims); // your DB
    req.session.regenerate(() => {
      req.session.userId = user.id;
      res.json({ id: user.id, email: user.email });
    });
  } catch (error) {
    res.status(401).json({ error: (error as Error).message });
  }
});

Next.js route handler

app/api/auth/paspo/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { exchangePaspoCode } from "@/server/paspo"; // the Node.js helper above

export async function POST(request: Request) {
  const { code, code_verifier, nonce, via } = await request.json();
  if (typeof code !== "string" || typeof code_verifier !== "string") {
    return NextResponse.json({ error: "invalid_request" }, { status: 400 });
  }
  try {
    const claims = await exchangePaspoCode({ code, code_verifier, nonce, via });
    const user = await upsertUserByPaspoSub(claims.sub!, claims);
    (await cookies()).set("session", await createSession(user.id), {
      httpOnly: true, secure: true, sameSite: "lax", path: "/",
    });
    return NextResponse.json({ id: user.id });
  } catch (error) {
    return NextResponse.json({ error: (error as Error).message }, { status: 401 });
  }
}

Using Auth.js / NextAuth instead? Register Paspo as an OIDC provider — see Manual integration.

Nuxt server route

server/api/auth/paspo.post.ts
import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(new URL("https://paspo.id/api/w/v1/oauth/jwks"));

export default defineEventHandler(async (event) => {
  const { code, code_verifier, nonce, via } = await readBody(event);
  if (typeof code !== "string" || typeof code_verifier !== "string") {
    throw createError({ statusCode: 400, statusMessage: "invalid_request" });
  }
  const config = useRuntimeConfig(event);

  const body = new URLSearchParams({
    grant_type: "authorization_code",
    code,
    code_verifier,
    client_id: config.public.paspoClientId,
    client_secret: config.paspoClientSecret,
  });
  if (via !== "fedcm") body.set("redirect_uri", config.paspoRedirectUri);

  const tokens = await $fetch<{ id_token: string }>("https://paspo.id/api/w/v1/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: body.toString(),
  }).catch(() => {
    throw createError({ statusCode: 401, statusMessage: "invalid_grant" });
  });

  const { payload } = await jwtVerify(tokens.id_token, JWKS, {
    issuer: "https://paspo.id",
    audience: config.public.paspoClientId,
    algorithms: ["RS256"],
  });
  if (nonce && payload.nonce !== nonce) {
    throw createError({ statusCode: 401, statusMessage: "nonce_mismatch" });
  }

  // Find or create the user by payload.sub, then start your session
  // (e.g. nuxt-auth-utils: await setUserSession(event, { user: { id } })).
  return { sub: payload.sub, email: payload.email };
});

Python (FastAPI)

pip install fastapi httpx "pyjwt[crypto]"
paspo.py
import os

import httpx
import jwt
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel

ISSUER = "https://paspo.id"
TOKEN_URL = "https://paspo.id/api/w/v1/oauth/token"
CLIENT_ID = os.environ["PASPO_CLIENT_ID"]
CLIENT_SECRET = os.environ["PASPO_CLIENT_SECRET"]
REDIRECT_URI = os.environ["PASPO_REDIRECT_URI"]

jwks = jwt.PyJWKClient("https://paspo.id/api/w/v1/oauth/jwks", cache_keys=True)
app = FastAPI()


class PaspoCode(BaseModel):
    code: str
    code_verifier: str
    nonce: str | None = None
    via: str | None = None


def exchange_paspo_code(data: PaspoCode) -> dict:
    form = {
        "grant_type": "authorization_code",
        "code": data.code,
        "code_verifier": data.code_verifier,
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
    }
    if data.via != "fedcm":
        form["redirect_uri"] = REDIRECT_URI

    response = httpx.post(TOKEN_URL, data=form, timeout=10)
    tokens = response.json()
    if response.status_code != 200:
        raise HTTPException(401, tokens.get("error", "token_request_failed"))

    key = jwks.get_signing_key_from_jwt(tokens["id_token"])
    claims = jwt.decode(
        tokens["id_token"], key.key, algorithms=["RS256"], audience=CLIENT_ID, issuer=ISSUER
    )
    if data.nonce and claims.get("nonce") != data.nonce:
        raise HTTPException(401, "nonce_mismatch")
    return claims


@app.post("/auth/paspo")
def paspo_sign_in(data: PaspoCode, request: Request):
    claims = exchange_paspo_code(data)
    user = upsert_user_by_paspo_sub(claims["sub"], claims)  # your DB
    # Start your session here (cookie, JWT, starlette SessionMiddleware…)
    return {"id": user.id, "email": claims.get("email")}

PHP

composer require firebase/php-jwt
paspo.php
<?php
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;

function exchange_paspo_code(string $code, string $verifier, ?string $nonce, ?string $via): array
{
    $form = [
        'grant_type'    => 'authorization_code',
        'code'          => $code,
        'code_verifier' => $verifier,
        'client_id'     => getenv('PASPO_CLIENT_ID'),
        'client_secret' => getenv('PASPO_CLIENT_SECRET'),
    ];
    if ($via !== 'fedcm') {
        $form['redirect_uri'] = getenv('PASPO_REDIRECT_URI');
    }

    $ch = curl_init('https://paspo.id/api/w/v1/oauth/token');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query($form),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
    ]);
    $tokens = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status !== 200) {
        throw new RuntimeException($tokens['error'] ?? 'token_request_failed');
    }

    // Cache this JSON (e.g. 1 hour) instead of fetching it on every sign-in.
    $jwks = json_decode(file_get_contents('https://paspo.id/api/w/v1/oauth/jwks'), true);
    $claims = (array) JWT::decode($tokens['id_token'], JWK::parseKeySet($jwks, 'RS256'));

    // php-jwt checks signature and exp; iss / aud / nonce are on you.
    $aud = (array) $claims['aud'];
    if ($claims['iss'] !== 'https://paspo.id' || !in_array(getenv('PASPO_CLIENT_ID'), $aud, true)) {
        throw new RuntimeException('invalid_id_token');
    }
    if ($nonce && ($claims['nonce'] ?? null) !== $nonce) {
        throw new RuntimeException('nonce_mismatch');
    }
    return $claims;
}

// POST /auth/paspo
$input = json_decode(file_get_contents('php://input'), true);
$claims = exchange_paspo_code($input['code'], $input['code_verifier'], $input['nonce'] ?? null, $input['via'] ?? null);
session_regenerate_id(true);
$_SESSION['paspo_sub'] = $claims['sub'];

Go

go get github.com/coreos/go-oidc/v3/oidc golang.org/x/oauth2
paspo.go
package auth

import (
    "context"
    "errors"
    "os"

    "github.com/coreos/go-oidc/v3/oidc"
    "golang.org/x/oauth2"
)

type PaspoClaims struct {
    Sub           string `json:"sub"`
    Email         string `json:"email"`
    EmailVerified bool   `json:"email_verified"`
    Phone         string `json:"phone_number"`
    Name          string `json:"name"`
    Nonce         string `json:"nonce"`
}

func ExchangePaspoCode(ctx context.Context, code, verifier, nonce, via string) (*PaspoClaims, error) {
    provider, err := oidc.NewProvider(ctx, "https://paspo.id") // cache it in production
    if err != nil {
        return nil, err
    }
    conf := oauth2.Config{
        ClientID:     os.Getenv("PASPO_CLIENT_ID"),
        ClientSecret: os.Getenv("PASPO_CLIENT_SECRET"),
        Endpoint:     provider.Endpoint(),
    }
    if via != "fedcm" {
        conf.RedirectURL = os.Getenv("PASPO_REDIRECT_URI")
    }

    token, err := conf.Exchange(ctx, code, oauth2.VerifierOption(verifier))
    if err != nil {
        return nil, err
    }
    rawIDToken, _ := token.Extra("id_token").(string)
    idToken, err := provider.Verifier(&oidc.Config{ClientID: conf.ClientID}).Verify(ctx, rawIDToken)
    if err != nil {
        return nil, err
    }
    var claims PaspoClaims
    if err := idToken.Claims(&claims); err != nil {
        return nil, err
    }
    if nonce != "" && claims.Nonce != nonce {
        return nil, errors.New("nonce_mismatch")
    }
    return &claims, nil
}

cURL

For a quick manual test (see Testing):

curl -X POST https://paspo.id/api/w/v1/oauth/token \
  -d grant_type=authorization_code \
  -d code=CODE \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET \
  -d redirect_uri=https://app.example.com/auth/paspo/callback \
  -d code_verifier=VERIFIER

Security checklist

  • client_secret lives only in server env variables and secret storage.
  • Identify users by sub, not by e-mail: the user may share a different address next time.
  • Regenerate the session ID after sign-in; set cookies HttpOnly; Secure; SameSite=Lax.
  • Don't log code, code_verifier, tokens or client_secret.
  • Don't send Paspo's access_token or id_token to the browser — issue your own session.
  • Use the access_token only if you call UserInfo; the id_token already carries the requested claims.