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) —
ASWebAuthenticationSessionon 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=noneanswersinteraction_requiredthere. 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=trueadds 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, withhttp://localhost:4001/callback/google(and/github) registered as callback URLs. - The iOS simulator shares your machine's
localhost; an Android emulator reaches it at10.0.2.2, and a real phone at your machine's network address. The issuer must match exactly, so run the broker withPASSKIN_URLset to the address the device uses.
13. Before going live
- Use a
productionproject (pk_live_…): websites accept onlyhttpsredirect 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.