Manual integration

Integrate Sign in with Paspo without the SDK — a standard OpenID Connect authorization code flow with PKCE.

Paspo is a standard OpenID Connect provider, so you don't need the SDK. Use it when:

  • your stack already has an OIDC library (Auth.js, Passport, allauth, Spring Security, ASP.NET…);
  • you render pages on the server and want a plain redirect, no frontend JavaScript;
  • you can't add npm dependencies.

The trade-off: a full-page redirect instead of the popup, and no FedCM prompt. Need the popup? Use the SDK — the window protocol isn't a public API.

The flow

Browser ──GET /auth/paspo/login──▶ Your server
                                    • state, nonce, code_verifier → session
Browser ◀──302 paspo.id/oauth/authorize?…──
Browser ──user confirms in Paspo──▶ paspo.id
Browser ◀──302 <redirect_uri>?code=…&state=…──
Browser ──GET <redirect_uri>?code&state──▶ Your server
                                    • check state
                                    • POST /api/w/v1/oauth/token
                                    • verify id_token, check nonce
                                    • find user by sub, start session
Browser ◀──302 /app──

1. Authorization request

Redirect the browser to https://paspo.id/oauth/authorize with:

ParameterRequired
client_idyesYour client ID
redirect_uriyesExactly one of the registered URIs
response_typeyescode — the only supported value
scopeyesSpace-separated: openid plus any of profile, email, phone
stateyesRandom, ≥ 128 bits. CSRF protection
code_challengeyesBASE64URL(SHA256(code_verifier)), no padding
code_challenge_methodyesS256
noncerecommendedRandom. Returned in the id_token
https://paspo.id/oauth/authorize
  ?client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fpaspo%2Fcallback
  &response_type=code
  &scope=openid%20email
  &state=Q2s9…
  &nonce=bX4r…
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

Store state, nonce and code_verifier server-side (session, or an encrypted HttpOnly; Secure; SameSite=Lax cookie with a 10-minute lifetime).

Generating PKCE values

import { createHash, randomBytes } from "node:crypto";

const codeVerifier = randomBytes(32).toString("base64url");
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
const state = randomBytes(16).toString("base64url");
const nonce = randomBytes(16).toString("base64url");

2. Callback

Paspo redirects to redirect_uri with either:

  • ?code=…&state=… — success;
  • ?error=access_denied&state=… — the user cancelled; other values are standard OAuth errors such as invalid_request or invalid_scope.

On success:

  1. Compare state with the stored value; reject on mismatch. Delete the stored values — one attempt per login.
  2. Exchange the code with client_secret, the same redirect_uri and the stored code_verifier.
  3. Verify the id_token and compare its nonce with the stored one.
  4. Find or create the user by sub, start your session, redirect into the app.

Node.js with openid-client

openid-client handles discovery, PKCE, state, nonce and id_token validation.

npm install openid-client express express-session
server.ts
import express from "express";
import session from "express-session";
import * as oidc from "openid-client";

const REDIRECT_URI = process.env.PASPO_REDIRECT_URI!; // https://app.example.com/auth/paspo/callback
const config = await oidc.discovery(
  new URL("https://paspo.id"),
  process.env.PASPO_CLIENT_ID!,
  process.env.PASPO_CLIENT_SECRET!,
);

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

app.get("/auth/paspo/login", async (req, res) => {
  const codeVerifier = oidc.randomPKCECodeVerifier();
  const state = oidc.randomState();
  const nonce = oidc.randomNonce();
  req.session.paspo = { codeVerifier, state, nonce };

  const url = oidc.buildAuthorizationUrl(config, {
    redirect_uri: REDIRECT_URI,
    scope: "openid email",
    code_challenge: await oidc.calculatePKCECodeChallenge(codeVerifier),
    code_challenge_method: "S256",
    state,
    nonce,
  });
  res.redirect(url.href);
});

app.get("/auth/paspo/callback", async (req, res) => {
  const pending = req.session.paspo;
  delete req.session.paspo;
  if (!pending) return res.redirect("/login?error=paspo");

  try {
    const tokens = await oidc.authorizationCodeGrant(
      config,
      new URL(req.originalUrl, REDIRECT_URI),
      { pkceCodeVerifier: pending.codeVerifier, expectedState: pending.state, expectedNonce: pending.nonce },
    );
    const claims = tokens.claims()!;
    const user = await upsertUserByPaspoSub(claims.sub, claims); // your DB
    req.session.regenerate(() => {
      req.session.userId = user.id;
      res.redirect("/");
    });
  } catch {
    res.redirect("/login?error=paspo");
  }
});

Auth.js (NextAuth)

auth.ts
import NextAuth from "next-auth";

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    {
      id: "paspo",
      name: "Paspo",
      type: "oidc",
      issuer: "https://paspo.id",
      clientId: process.env.AUTH_PASPO_ID,
      clientSecret: process.env.AUTH_PASPO_SECRET,
      authorization: { params: { scope: "openid email" } },
      checks: ["pkce", "state", "nonce"],
    },
  ],
});

Register the redirect URI https://app.example.com/api/auth/callback/paspo. Start sign-in with signIn("paspo").

Django (django-allauth)

settings.py
INSTALLED_APPS += ["allauth.socialaccount.providers.openid_connect"]

SOCIALACCOUNT_PROVIDERS = {
    "openid_connect": {
        "OAUTH_PKCE_ENABLED": True,
        "APPS": [
            {
                "provider_id": "paspo",
                "name": "Paspo",
                "client_id": os.environ["PASPO_CLIENT_ID"],
                "secret": os.environ["PASPO_CLIENT_SECRET"],
                "settings": {"server_url": "https://paspo.id", "scope": ["openid", "email"]},
            }
        ],
    }
}

Redirect URI: https://app.example.com/accounts/oidc/paspo/login/callback/.

Spring Boot (Spring Security)

application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          paspo:
            client-id: ${PASPO_CLIENT_ID}
            client-secret: ${PASPO_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            scope: openid,email
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          paspo:
            issuer-uri: https://paspo.id

Spring adds PKCE automatically only for public clients. For a confidential client, enable it with OAuth2AuthorizationRequestCustomizers.withPkce() on the authorization request resolver. Redirect URI: https://app.example.com/login/oauth2/code/paspo.

ASP.NET Core

Program.cs
builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = "paspo";
    })
    .AddCookie()
    .AddOpenIdConnect("paspo", options =>
    {
        options.Authority = "https://paspo.id";
        options.ClientId = builder.Configuration["Paspo:ClientId"];
        options.ClientSecret = builder.Configuration["Paspo:ClientSecret"];
        options.ResponseType = "code";
        options.UsePkce = true;
        options.CallbackPath = "/auth/paspo/callback";
        options.Scope.Clear();
        options.Scope.Add("openid");
        options.Scope.Add("email");
        options.MapInboundClaims = false;
    });

Other stacks

Any certified OpenID Connect client works: point it at the issuer https://paspo.id, enable PKCE S256, and use client_secret_post or client_secret_basic. Not sure how? Use the manual AI prompt.

Don'ts

  • No response_type=token (implicit flow) — not supported.
  • No iframe for the authorize page — the Paspo session cookie is not sent to third-party frames.
  • No wildcard or prefix redirect URIs — they must match exactly.
  • No client_secret in frontend code or mobile apps.