Map Ppoppo users to your database
When a user signs in, UserInfo returns their sub — the ppnum_id, an immutable ULID that is your stable key for that user. This guide shows a minimal schema and the find-or-create logic. For where sub comes from, see the OAuth2 PKCE guide.
Schema
Store ppnum_id on your user record, and keep the refresh token on the session, not the user:
CREATE TABLE users (
id VARCHAR(26) PRIMARY KEY, -- your user id (ULID recommended)
name VARCHAR(100) NOT NULL, -- your application fields
ppnum_id VARCHAR(26) NOT NULL UNIQUE, -- the OAuth `sub` claim — the only stable link
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_sessions (
id VARCHAR(26) PRIMARY KEY,
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
refresh_token_ciphertext BYTEA, -- encrypted at rest, never plaintext
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Store ppnum_id (and optionally ppnum). Never cache email — it's mutable and owned by PAS, so a cached copy drifts the moment the user changes it; fetch it from UserInfo when you need it. The refresh token belongs to a session (a logout CASCADE-deletes it) and must be encrypted at rest.
Handling the callback
Find-or-create by ppnum_id, then open a session:
async def handle_oauth_callback(code, state, code_verifier):
tokens = await ppoppo.exchange_code(code, code_verifier)
user = await ppoppo.get_user_info(tokens.access_token) # -> sub (ppnum_id), ppnum
record = (await db.find_user_by_ppnum_id(user.sub)
or await db.create_user(ppnum_id=user.sub))
session = await db.create_session(
user_id=record.id,
refresh_token_ciphertext=encrypt(tokens.refresh_token),
)
set_cookie("session_id", session.id, http_only=True, secure=True)
return recordThat's the whole integration point: one stable identifier (ppnum_id), an encrypted session-scoped refresh token, and no cached PAS-owned data. See The ppnum model for the identifiers, and Trust boundary for the ownership rule.