Passkin

Adding Passkin sign-in to a website or an app

Passkin gives a website, a single-page app, an iOS or Android app, or a desktop app Google, GitHub and email-code sign-in without registering OAuth apps of its own. Sign-in runs through Passkin's apps, and your code receives a standard OpenID Connect result. Passkin is a service for developers: your users see your product's sign-in page, never a Passkin account of their own.

Everything below is plain OpenID Connect. Any OIDC library works unchanged; nothing here is specific to Passkin except the values.

1. Projects and applications

A project is one product: its users, its signing keys, its issuer, its session rules. An application is one way into it.

Project "Shop"                     issuer  https://auth.passk.in/p/pk_live_shop…
├── Website            web         client_id pk_live_shop…   (the publishable key)
├── Shop for iPhone    ios         client_id pk_live_8Hk2…   → com.example.shop:/callback
├── Shop for Android   android     client_id pk_live_Qz7c…   → com.example.shop:/callback
└── Shop for Mac       desktop     client_id pk_live_Vb1m…   → http://127.0.0.1/callback (any port)
  • A website and its apps on the same backend are one project. Someone who signs in on the website and later in the iPhone app is one user: the same sub, the same profile, one ban.
  • Each application has its own client id and return addresses, so you can see where everyone signs in. The dashboard shows each user's platforms, each session's application and each application's numbers.
  • Two different products are two projects. They get different subs for the same person, and neither can match the other's users.
  • Every project starts with one application, its website, whose client id is the project's publishable key. Add the others in the dashboard (Applications) or with the Management API.
Type What it is Return address Secret key
web A site with a server (Next.js, Laravel, Rails, Django, Express…) https://…; http://localhost in development may send it; can be required
spa Browser code with no server of its own, or widget.js https://…; http://localhost in development never: PKCE only
ios iPhone and iPad its own scheme com.example.shop:/callback, or a Universal Link https://… never: PKCE only
android Android its own scheme, or a verified App Link https://… never: PKCE only
desktop Windows, macOS, Linux, Electron, Tauri, a CLI http://127.0.0.1/callback (any port matches), its own scheme, or https://… never: PKCE only

A secret shipped inside an app or a page is a secret everyone has, so Passkin refuses a secret from an spa, ios, android or desktop client id rather than ignoring it. An app's own scheme must be reverse-domain (com.example.shop:, with a dot), which another app is unlikely to claim.

How an app signs in: natively, or through Passkin's page

An iOS or Android app has two ways in, and uses both:

  • Native (recommended for Google and Apple). The phone's own sheet — on Android, Google's account picker sliding up over the app; on iPhone, Sign in with Apple with Face ID — then the app hands the ID token to Passkin and gets Passkin tokens back (§8). No browser, no page. For this, each app has its own client at the provider: in the same Google Cloud project as the web client, an Android client per signing certificate and an iOS client, all under the same consent screen. They add no users and no duplicates: Google gives the same account the same id in every client of a project, so the person is the same user on the website and in the app.
  • Through Passkin's page. For GitHub, email codes and every method without a native SDK, the app opens Passkin in the system browser (RFC 8252) — ASWebAuthenticationSession on iOS, a Custom Tab on Android, the default browser on desktop — and Passkin sends the person back. Here Google only sees Passkin's own web client.

Never sign in inside a WebView or an app's own window: Google refuses sign-in in embedded browsers (disallowed_useragent), and the person cannot see whose page they are typing into.

2. Your project's values

Value What it is Where it goes
Publishable key pk_live_… / pk_test_… Names the project (its issuer) and is the website's client_id. Public. Browser and server
Client id of an application That app's client_id. Public. The app
Secret key sk_live_… Proves your server is you. Shown once, stored only hashed. Server only
Issuer https://auth.passk.in/p/<publishable key> Each project is its own issuer with its own signing key. The same for all its applications. Your OIDC library
Redirect URIs (per application) Exact addresses Passkin may send a code to. On loopback any port matches (RFC 8252 §7.3). Applications
Allowed origins (websites, SPAs) Origins whose browser code may call the token endpoint. Applications
Logout URLs (per application) Where Passkin may send someone after signing out. Applications
Sign-in methods Any of google, github, email — for the whole project. Sign-in methods
Session lifetime / idle days How long someone stays signed in to your product (default 90 / 30). Settings
Access token TTL Seconds, default 900. Settings
Require client secret Websites must send the secret key at the token endpoint. Apps are held to PKCE instead. Settings

In development the broker seeds sites you can use immediately:

Demo App Demo Notes
Publishable key pk_test_passkin_demo_app pk_test_passkin_demo_notes
Secret key sk_test_passkin_demo_app_local_only sk_test_passkin_demo_notes_local_only
Redirect URIs http://localhost:3000/api/auth/callback, http://localhost:3000/callback http://localhost:3002/api/auth/callback, http://localhost:3002/callback
An iOS app client id pk_test_passkin_demo_ios, com.passkin.demo:/callback

3. Discovery

GET https://auth.passk.in/p/<pk>/.well-known/openid-configuration
Endpoint Path under the issuer
Authorization /authorize
Token /token
UserInfo /userinfo
Keys /jwks.json
Revocation (RFC 7009) /revoke
Logout (RP-initiated) /logout

4. The flow

Authorize

Send the person to /authorize with:

Parameter
client_id the application's client id (the publishable key, for the website)
redirect_uri one of that application's registered URIs, exactly
response_type code (the only one)
scope openid plus any of email, profile, offline_access
code_challenge, code_challenge_method=S256 required: PKCE, for every client
state recommended; returned unchanged
nonce recommended; returned in the id_token
prompt none (never show a page), login (always sign in again), consent, select_account
max_age seconds; older sign-ins must sign in again
ui_locales ar or en; otherwise the browser's language
login_hint an email to pre-fill
connection google, github or email: for a site or app with its own buttons. google/github skip the chooser and go straight there; email shows only the email form (with login_hint filled in) and a link to the other ways. Ignored when the person is already signed in, or the method is off.

What the person sees:

  • First time on your product, or signed out: Google · GitHub · email code (whichever you enabled). After signing in they come straight back to you — Passkin shows no page of its own.
  • Signed in to your product before (on its website or in its apps): nothing. The browser comes straight back with a code.
  • Signed in to another developer's product: nothing carries over. Each project is its own sign-in; nobody is told Passkin knows them from elsewhere.
  • prompt=consent: a page listing what your product will receive, with Continue — only when you ask for it.
  • An app coming back through its own scheme or loopback: one «Continue» press, every time, even when allowed before. Any app on a device can claim a scheme or listen on a port, so Passkin does not hand out a code to one without the person's press (RFC 8252 §8.6). prompt=none answers interaction_required there. A Universal Link or App Link (https, proven by your domain) is answered like a website.

Passkin's page always names who is asking: the website's host, or «iOS app · com.example.shop» for an app.

The browser returns to redirect_uri with code, state and iss (RFC 9207). Errors come back as error and error_description (access_denied, login_required, interaction_required, invalid_request, …). If the redirect_uri is not registered for that application, Passkin shows an error page and does not redirect.

Token

curl -X POST https://auth.passk.in/p/$PK/token \
  -d grant_type=authorization_code \
  -d code=$CODE \
  -d code_verifier=$VERIFIER \
  -d redirect_uri=https://app.example.com/callback \
  -d client_id=$CLIENT_ID
# a website's server may add -u "$CLIENT_ID:$SK" (HTTP Basic) or -d client_secret=$SK

The code works once, for two minutes, and only with the same client_id, redirect_uri and PKCE verifier: a code issued to the iOS app cannot be redeemed as the website. Presented a second time, it fails and ends the session it created.

{
  "access_token": "eyJ…",
  "token_type": "Bearer",
  "expires_in": 900,
  "id_token": "eyJ…",
  "refresh_token": "rt_…",
  "scope": "openid email profile offline_access"
}

What the tokens say

sub is your user id for this person: the same on your website and in every app of the project, different on every other project.

Claim In When
iss, sub, exp, iat, sid, auth_time both always
aud id_token: the application's client id · access token: the project's publishable key always
azp (the application), nonce, amr (google / github / email) id_token always / when sent / always
client_id (the application), scope, jti access token always
email, email_verified both scope email
name, given_name, family_name, picture, locale, updated_at id_token, userinfo scope profile

The id token is for the application that asked. The access token is for your backend, whichever application asked — so one API serves them all.

Refresh

curl -X POST https://auth.passk.in/p/$PK/token -d grant_type=refresh_token -d refresh_token=$RT -d client_id=$CLIENT_ID

Every refresh returns a new refresh token; keep the new one. A refresh token that was already used, presented again more than ten seconds later, means it was copied: the whole session ends, and the person signs in again. (Two tabs refreshing at the same moment are fine.) A refresh token works only for the application it was issued to. Refreshing stops when your session lifetime or idle limit is reached, when you end the session or ban the person, or when you delete the application.

5. One backend for the website and the apps

Every application sends its access token as Authorization: Bearer …. Verify it locally — it is a JWT (RFC 9068, header typ: at+jwt):

import { createRemoteJWKSet, jwtVerify } from "jose";

const issuer = "https://auth.passk.in/p/pk_live_…";
const JWKS = createRemoteJWKSet(new URL(`${issuer}/jwks.json`));

const { payload } = await jwtVerify(token, JWKS, {
  issuer,
  audience: "pk_live_…",        // the project, for every application
  typ: "at+jwt",
  algorithms: ["RS256"],
});
// payload.sub — the person, the same on the website and in the apps
// payload.client_id — which application called

With the Next.js SDK, the same in one line — and no cookie secret is needed for a backend that only answers apps:

import { verifyRequest } from "@/lib/passkin";

export async function GET(request: Request) {
  const caller = await verifyRequest(request);   // { userId, clientId, scopes, sessionId, expiresAt } | null
  if (!caller) return new Response("Sign in", { status: 401 });
  return Response.json(await ordersOf(caller.userId));
}

A signed token stays valid until it expires (the project's access token lifetime). For a decision that must see a revocation now (a ban, a sign-out elsewhere), call /userinfo with the token, which checks the session in the database. GET /v1/project with the secret key lists the project's applications, so a backend can name each client_id.

6. Websites

Next.js — the SDK (@unipass/nextjs)

Four files. The session lives in an encrypted, httpOnly cookie; tokens never reach the browser's JavaScript.

# .env.local
PASSKIN_PUBLISHABLE_KEY=pk_live_…
PASSKIN_COOKIE_SECRET=$(openssl rand -base64 32)
PASSKIN_SECRET_KEY=sk_live_…                   # optional
PASSKIN_URL=https://auth.passk.in
NEXT_PUBLIC_PASSKIN_URL=https://auth.passk.in  # for the account link
# PASSKIN_CLIENT_ID=pk_live_…                  # only for a second website of the same project
// lib/passkin.ts
import { createPasskin } from "@unipass/nextjs/server";
export const { handlers, auth, currentUser, protect, verifyRequest } = createPasskin();

// app/api/auth/[...passkin]/route.ts
import { handlers } from "@/lib/passkin";
export const { GET, POST } = handlers;

// middleware.ts — keeps sessions fresh; guards pages
import { passkinMiddleware } from "@unipass/nextjs/middleware";
export default passkinMiddleware({ protect: ["/dashboard"] });
export const config = { matcher: ["/((?!_next/|favicon.ico).*)"] };
// app/layout.tsx
import { PasskinProvider } from "@unipass/nextjs";
import { currentUser } from "@/lib/passkin";

export default async function RootLayout({ children }) {
  return (
    <html><body>
      <PasskinProvider initialUser={await currentUser()} passkinUrl={process.env.NEXT_PUBLIC_PASSKIN_URL}>
        {children}
      </PasskinProvider>
    </body></html>
  );
}

Register https://<your site>/api/auth/callback as a redirect URI of the website application. Then:

await auth() { userId, sessionId, user, accessToken, expiresAt } or null — server components, route handlers, server actions
await currentUser() the user or null
await protect("/here") the session, or a redirect to sign in that comes back to /here
await verifyRequest(request) the caller named by a Bearer access token from any application, or null (§5)
<SignedIn> / <SignedOut> render by state
<SignInButton returnTo> / <SignOutButton> / <SignIn> sign-in happens on Passkin's page; these are the doors to it
<UserButton> avatar menu: who, "Manage your account", sign out
useUser() / usePasskin() { user, isLoaded, isSignedIn }, signIn(), signOut(), refresh()

The middleware refreshes the access token a minute before it expires and hands the new cookie to the page rendering the same request. Refresh tokens rotate, so a refresh only happens where the new cookie can be saved; without the middleware, a server component treats an expired session as signed out (the next sign-in is a silent round trip while the person is signed in to Passkin). Signing out (POST /api/auth/signout, what <SignOutButton> does) revokes the session at Passkin and clears the cookie; posts from other origins are refused. apps/example-next is a working app built this way.

Auth.js (NextAuth)

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

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    {
      id: "passkin",
      name: "Passkin",
      type: "oidc",
      issuer: `https://auth.passk.in/p/${process.env.PASSKIN_PUBLISHABLE_KEY}`,
      clientId: process.env.PASSKIN_PUBLISHABLE_KEY,
      clientSecret: process.env.PASSKIN_SECRET_KEY,
      checks: ["pkce", "state", "nonce"],
      authorization: { params: { scope: "openid email profile" } },
    },
  ],
});

Register https://<your site>/api/auth/callback/passkin as a redirect URI.

openid-client (any Node server)

import * as oidc from "openid-client";

const pk = process.env.PASSKIN_PUBLISHABLE_KEY!;
const config = await oidc.discovery(new URL(`https://auth.passk.in/p/${pk}`), pk, undefined, oidc.None());

// start
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(config, {
  redirect_uri: "https://app.example.com/callback",
  scope: "openid email profile offline_access",
  code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
  code_challenge_method: "S256",
  state,
});
// keep verifier + state in an httpOnly cookie, redirect to url

// callback
const tokens = await oidc.authorizationCodeGrant(config, new URL(req.url, "https://app.example.com"), {
  pkceCodeVerifier: verifier,
  expectedState: state,
});
const claims = tokens.claims(); // sub, email, name, …

7. Single-page apps

Add a Single-page app application with the page Passkin returns to as its redirect URI and the page's origin under allowed origins (the browser calls the token endpoint itself).

widget.js — no build step

<script src="https://auth.passk.in/widget.js"
        data-client-id="pk_live_…"
        data-project="pk_live_…"></script>
<div data-passkin-button></div>
<script>
  Passkin.onUser(function (user) {
    // null when signed out; { id, email, name, picture, … } when signed in
  });
</script>
Attribute
data-client-id the application's client id (required)
data-project the project's publishable key, when it differs from the client id (an spa application); default: the client id
data-redirect-uri default: this page's URL without its query — register it
data-scope default openid email profile offline_access
data-lang ar or en; default the page's lang
data-storage local to stay signed in across tabs; default session (this tab)

Passkin.signIn({ returnTo, prompt }), Passkin.signOut(), await Passkin.getAccessToken() (refreshes when needed; send it to your API as a Bearer token), Passkin.user, Passkin.accountUrl, and a passkin:user event on window.

It runs PKCE in the browser, exchanges the code at the token endpoint, verifies the id token against the application and the access token against the project with Web Crypto, and removes the code from the address bar. The button renders in a shadow root with a constructed stylesheet, so it works under a strict style-src CSP. Tokens are in browser storage: for anything sensitive, keep them on a server instead (a web application).

oidc-client-ts — React, Vue, Svelte…

import { UserManager } from "oidc-client-ts";

const passkin = new UserManager({
  authority: "https://auth.passk.in/p/pk_live_…",
  client_id: "pk_live_…",               // the SPA application's client id
  redirect_uri: "https://app.example.com/callback",
  scope: "openid email profile offline_access",
});

await passkin.signinRedirect();              // on "Sign in"
const user = await passkin.signinCallback(); // on the page Passkin returns to
// user.access_token → your API, as "Authorization: Bearer …"

8. iOS and Android apps

Native sign-in — Google and Apple, no browser

Two requests, either side of the provider's own sheet:

POST /p/<pk>/native/nonce          client_id=<the app's client id>
→ { "nonce": "nn_…", "expires_in": 600 }

   … the app shows Google's or Apple's sheet, passing that nonce …

POST /p/<pk>/token
  grant_type=urn:ietf:params:oauth:grant-type:token-exchange
  subject_token=<the ID token Google or Apple gave the app>
  subject_token_type=urn:ietf:params:oauth:token-type:id_token
  subject_issuer=google | apple
  nonce=nn_…
  client_id=<the app's client id>
  scope=openid email profile offline_access
  given_name=… family_name=…        (Apple only, the first time: Apple gives the name to the app, not in the token)
→ the usual token response (access_token, id_token, refresh_token)

Passkin accepts the ID token only when all of this holds:

  • it is signed by Google or Apple, fresh (issued in the last ten minutes) and addressed to the project's Google web client (the app's server client id) or to the app's own client;
  • it was issued to this app's own client at the provider — a Google Android/iOS client id, or the Apple bundle id, registered on this application under Native sign-in. Each is registered to one application in all of Passkin, so a token another app received cannot be spent as yours;
  • it carries the nonce Passkin issued for this application (as is, or its SHA-256 in hex), which is then spent.

The result is the same as the browser flow: the same project user (a person who signed in with the same Google account on the website is the same user), the same ban and policy checks, a session marked with the app and native, and tokens whose aud and client_id are the app's. Refresh as usual.

Set up, Android: on Passkin's Google app, Turn on native Google sign-in on the app's page with the SHA-1 of each signing certificate (debug, release, Play App Signing). With your own Google app, create an OAuth client of type Android for each in your project, beside the web client, and add their ids there. Then Credential Manager, with the server client id the page shows:

// androidx.credentials:credentials + credentials-play-services-auth, com.google.android.libraries.identity.googleid:googleid
val nonce = postForm("$PASSKIN/native/nonce", mapOf("client_id" to clientId)).getString("nonce")
val option = GetGoogleIdOption.Builder()
  .setServerClientId(SERVER_CLIENT_ID)     // the project's Google web client
  .setFilterByAuthorizedAccounts(false)
  .setNonce(nonce)
  .build()
val result = CredentialManager.create(activity)
  .getCredential(activity, GetCredentialRequest.Builder().addCredentialOption(option).build())
val google = GoogleIdTokenCredential.createFrom(result.credential.data)
val tokens = postForm("$PASSKIN/token", mapOf(
  "grant_type" to "urn:ietf:params:oauth:grant-type:token-exchange",
  "subject_token" to google.idToken,
  "subject_token_type" to "urn:ietf:params:oauth:token-type:id_token",
  "subject_issuer" to "google",
  "nonce" to nonce,
  "client_id" to clientId,
  "scope" to "openid email profile offline_access"))

Set up, iPhone: add the Sign in with Apple capability in Xcode and turn on Sign in with Apple under Native sign-in (it uses the bundle id). Then:

let nonce = try await postForm("\(passkin)/native/nonce", ["client_id": clientId])["nonce"] as! String
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.fullName, .email]
request.nonce = nonce
let apple = try await signInWithApple(request)     // ASAuthorizationController + its delegate
let tokens = try await postForm("\(passkin)/token", [
  "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
  "subject_token": String(decoding: apple.identityToken!, as: UTF8.self),
  "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
  "subject_issuer": "apple",
  "nonce": nonce,
  "client_id": clientId,
  "given_name": apple.fullName?.givenName ?? "",
  "family_name": apple.fullName?.familyName ?? ""
])

For Google on iPhone, turn native Google sign-in on from the app's page (Passkin registers the iOS client; with your own Google app, create one with the bundle id and add it), and use GoogleSignIn-iOS with serverClientID set to the server client id and the nonce passed to signIn(withPresenting:hint:additionalScopes:nonce:); exchange result.user.idToken the same way with subject_issuer=google. Be aware that an iPhone has no Google account to pick from: Google's own SDK opens Google's page in a system sheet too. The native sheet on iPhone is Apple's — and an app that offers Google is expected to offer Apple as well (App Store guideline 4.8).

On Passkin's Google app — the default — you bring nothing. Google accepts an Android or iOS client only in the same Google Cloud project as the web client, so Passkin registers your app there for you: on the app's page, Turn on native Google sign-in with your signing certificates' SHA-1 (Android) or your bundle id (iOS). Passkin attaches the client and native sign-in turns on; the in-app sheet works meanwhile. With your own Google app (optional), you create the Android/iOS clients in your project and add their ids yourself.

The in-app sign-in sheet — every method, nothing to set up

The app shows its own buttons — Continue with Google, GitHub, email — and each opens Passkin in the system's sign-in sheet: ASWebAuthenticationSession on iOS, a Custom Tab on Android. It slides over the app, goes straight to the method pressed (connection=google, github or email), and closes by itself when the person is done; they never leave the app for Safari or Chrome. This is how most apps sign in with Google on iPhone, and it works on Passkin's shared Google app with nothing to set up.

Add the app's own reverse-domain scheme — com.example.shop:/callback — or a Universal Link / App Link as its redirect URI, and use AppAuth, the OpenID Foundation's library for native apps: it opens the sheet, adds PKCE, state and nonce, and catches the redirect. Pass the button as additionalParameters: ["connection": "google"] (iOS) or setAdditionalParameters(mapOf("connection" to "google")) (Android).

iOS — AppAuth-iOS

Register the scheme under URL Types in Info.plist (com.example.shop), then:

import AppAuth

let issuer = URL(string: "https://auth.passk.in/p/pk_live_…")!

OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { config, _ in
  guard let config else { return }
  let request = OIDAuthorizationRequest(
    configuration: config,
    clientId: "pk_live_…",                     // the iOS application's client id
    scopes: [OIDScopeOpenID, OIDScopeEmail, OIDScopeProfile, "offline_access"],
    redirectURL: URL(string: "com.example.shop:/callback")!,
    responseType: OIDResponseTypeCode,
    additionalParameters: nil)
  self.flow = OIDAuthState.authState(byPresenting: request, presenting: viewController) { state, _ in
    // state?.lastTokenResponse?.accessToken → your backend, as "Authorization: Bearer …"
    // Keep `state` in the Keychain; state.performAction(freshTokens:) refreshes.
  }
}

Android — AppAuth-Android

// build.gradle.kts
dependencies { implementation("net.openid:appauth:0.11.1") }
android { defaultConfig { manifestPlaceholders["appAuthRedirectScheme"] = "com.example.shop" } }
val service = AuthorizationService(context)

AuthorizationServiceConfiguration.fetchFromIssuer(Uri.parse("https://auth.passk.in/p/pk_live_…")) { config, _ ->
  val request = AuthorizationRequest.Builder(
      config!!, "pk_live_…", ResponseTypeValues.CODE, Uri.parse("com.example.shop:/callback"))
    .setScopes("openid", "email", "profile", "offline_access")
    .build()
  signIn.launch(service.getAuthorizationRequestIntent(request))
}

// in the ActivityResult callback
val response = AuthorizationResponse.fromIntent(result.data!!)
service.performTokenRequest(response!!.createTokenExchangeRequest()) { tokens, _ ->
  // tokens?.accessToken → your backend, as "Authorization: Bearer …"
}

React Native and Flutter wrap the same libraries (react-native-app-auth, flutter_appauth); the values are the same.

9. Desktop apps

Add a Desktop app application with http://127.0.0.1/callback. The app opens the default browser and listens on 127.0.0.1 on any free port; the registered address matches every port. A private scheme (com.example.notes:/callback) works too.

import http from "node:http";
import * as oidc from "openid-client";
import { shell } from "electron";

const config = await oidc.discovery(new URL("https://auth.passk.in/p/pk_live_…"), "pk_live_…", undefined, oidc.None());
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();

const server = http.createServer();
await new Promise((ready) => server.listen(0, "127.0.0.1", ready));
const redirect_uri = `http://127.0.0.1:${server.address().port}/callback`;

await shell.openExternal(oidc.buildAuthorizationUrl(config, {
  redirect_uri, state, scope: "openid email profile offline_access",
  code_challenge: await oidc.calculatePKCECodeChallenge(verifier), code_challenge_method: "S256",
}).href);

const answer = await new Promise((done) => server.once("request", (req, res) => {
  res.end("Signed in. You can close this tab.");
  done(new URL(req.url, redirect_uri));
}));
server.close();
const tokens = await oidc.authorizationCodeGrant(config, answer, { pkceCodeVerifier: verifier, expectedState: state });

10. Backend API

Your server, with the secret key:

curl https://auth.passk.in/v1/users -H "Authorization: Bearer $SK"
GET /v1/project the project the key belongs to, its issuer, and its applications (client_id, name, type)
GET /v1/users?limit=&offset=&email= your users, newest first, each with platforms (web, ios, …)
GET /v1/users/:id one user: email, name, picture, sign-in methods, platforms, when they connected, metadata
PATCH /v1/users/:id { "public_metadata": {…}, "private_metadata": {…} } (8 KB each)
POST /v1/users/:id/ban · /unban a ban ends every session, on every application, at once
DELETE /v1/users/:id removes the person from your product; if no other project has them, Passkin deletes its record of them too
GET /v1/users/:id/sessions sessions, with status and the application each came through
POST /v1/sessions/:id/revoke ends one session

Names and pictures come from the provider the person signed in with. Your server writes metadata, not their profile. Banning or deleting someone ends their sessions on every application at once (refresh fails, /userinfo answers 401).

11. Signing out

Sign the person out of your site or app (end your own session), then optionally end the Passkin session for that application:

GET https://auth.passk.in/p/<pk>/logout?id_token_hint=<id_token>&post_logout_redirect_uri=https://app.example.com/bye&state=…

post_logout_redirect_uri must be one of the logout URLs of the application the id token was issued to. This ends that session; the person stays signed in to Passkin, to your other applications, and to other sites.

12. Local development

pnpm install
PASSKIN_DEV_LOGIN=true pnpm --filter @unipass/auth-broker dev
  • No database, Redis or mail provider needed: an embedded Postgres runs in memory and email codes are printed in the broker's console.
  • PASSKIN_DEV_LOGIN=true adds a «Development sign-in» form that accepts any email without proof, so you can test without Google credentials. The broker refuses to start with it in production.
  • Google and GitHub appear once MANAGED_GOOGLE_* / MANAGED_GITHUB_* are set, with http://localhost:4001/callback/google (and /github) registered as callback URLs.
  • The iOS simulator shares your machine's localhost; an Android emulator reaches it at 10.0.2.2, and a real phone at your machine's network address. The issuer must match exactly, so run the broker with PASSKIN_URL set to the address the device uses.

13. Before going live

  • Use a production project (pk_live_…): websites accept only https redirect URIs there.
  • Keep the secret key on your server; never in an app or a page. Switch on require client secret if every website token request comes from your server.
  • Apps: native sign-in for Google and Apple, with every Android signing certificate's client registered; otherwise the system browser (never a WebView), with a reverse-domain scheme or a Universal Link / App Link.
  • Verify access tokens with the issuer, the project as audience, and typ: at+jwt; never decode without verifying.
  • Store refresh tokens server-side, in an httpOnly cookie, or in the Keychain / Keystore, and replace them on every refresh.
  • Choose your session lifetime and idle limit.

Ready to try it?

Create a project — the dashboard fills these snippets in with your own keys.

Open the dashboard