Sign in with allme
Add a “Sign in with allme” button to your website or app. The person taps it, confirms on their phone (or on the web), and your backend receives a verified identity — and, if you ask for them, a few profile fields or a lasting connection. It is a normal OAuth 2.0 authorization-code + PKCE flow with an allme-specific consent screen.
idw_… OAuth client) in the portal; the person consents on this client. All values a person shares are end-to-end encrypted to your app's key — the platform never sees them.Which flow do I use?
Every request declares a mode. Pick by what you need from the person:
| mode | What you get | Persistence | Use it when |
|---|---|---|---|
signin |
The person's identity: a stable sub — which IS their share_code. (A display name is not part of identity: ask for it as a name claim.) |
None beyond the login. Read once from userinfo. |
You just need “log in / sign up with allme” — no profile data. |
one_time |
Identity plus the specific fields you asked for (the claims), as a frozen one-time snapshot. |
The values are handed over once (E2E-encrypted to your app key) and are not kept in sync. | Checkout / onboarding: you need an email, phone, or address this once and will store your own copy. |
connect |
A lasting connection to one of your services — live, auto-updating fields over the service data API and webhooks. | Permanent until the person disconnects. Values stay current. | You want an ongoing relationship (a customer record that stays up to date). The app must reference a service. |
A fourth mode, 2fa_enroll, uses this same surface to enroll the person in 2FA by allme for your service; it delivers no identity or claims.
Relation to the other connect paths. connect mode is a thin front door on top of the normal service connection: the person answers your service's request fields on the consent screen itself — one screen, no second dialog — and approving creates or joins a regular service connection you then read with your service data client. It also returns the answers to you directly, exactly as one_time does: call userinfo and read values. The consent screen answers every row of your service's catalog, binary rows included (those whose type resolves to a file storage lane — Field types); a file reaches you over the service data API, not in values. connect needs your company on the Basic level or above. signin/one_time live entirely on this OAuth surface — no data client, no service required.
How do I get the result back — redirect or detached?
Independently of mode, pick a response_mode for how the authorization code reaches you:
| response_mode | How it works | Best for |
|---|---|---|
redirect default |
After consent the person's browser/app is redirected to your redirect_uri?code=…&state=…. Classic OAuth. |
Desktop web, or any context where the same browser tab that started the flow should land back on your callback. |
detached |
Consent happens on the person's phone; nothing redirects your page. Your backend polls POST /oauth2/result with the state until the code appears (delivered exactly once). |
“Approve on your phone” UX, cross-device sign-in, and avoiding a stray browser tab. |
1. Register your app
In the allus portal → Configuration → OAuth apps:
- Create an app with a name, one or more exact redirect URIs (absolute
https, or a custom scheme / app link for native apps — no wildcards), and choose public (PKCE) or confidential. - Plain
httpis accepted for local development only — onlocalhost,127.0.0.1, and the private IP ranges10.x.x.x,172.16–31.x.xand192.168.x.x. That is what lets you test from a phone against a callback on your own machine: register the LAN address you actually browse to, for examplehttp://192.168.1.50:8091/callback. Use the IP literal, not a hostname — a name such asmymachine.localis refused. Everything else must behttps, and an http redirect sends the authorization code unencrypted across your network, so keep those registrations to development apps. - The portal generates a zero-knowledge keypair in your browser: you download the private key (protected by a passphrase shown once). This key decrypts the claim values delivered in
one_timeandconnectmode; the platform only stores the public key. - You get a
client_id(anidw_…id) and, for confidential apps, aclient_secretshown once. - For
connectapps, reference one of your services. Optionally require 2FA (also settable per service).
one_time or connect, or an encrypted-delivery OIDC login). A pure signin integration needs no key handling at all — just the client_id.2. Send the person to the consent screen
Point your button at https://web.allme.fyi/auth with the request in the query string. The allme apps claim that link (universal / app link) and open the native consent screen; the web app is the no-app fallback.
<a href="https://web.allme.fyi/auth?client_id=idw_...&mode=signin&response_mode=redirect
&redirect_uri=https%3A%2F%2Fshop.example%2Fcallback&state=RANDOM">
Sign in with allme
</a>
Parameters: client_id, mode (signin|one_time|connect), response_mode (redirect|detached), redirect_uri (URL-encoded, must exactly match a registered one), state (your CSRF nonce — always verify it on return), and for public clients a PKCE code_challenge (+code_challenge_method=S256).
claims is honoured in connect mode too, and what the consent screen asks for is the resolved claim set — not your URL's array on its own. The resolved set is your request's claims combined with every claim your company declared on the OAuth app (plus, on a standards OIDC sign-in, the scope presets you asked for). Where the same claim name appears in both, the declaration is a floor: required and verified are raised and never lowered, mandatory_connected, the hint and the stay-connected binding come from the declaration, and your request keeps type, suggest and label. So sending claims does not narrow the screen to your array: a claim your company declared and your code never mentioned still renders. If a row appears that your request did not ask for, look at the OAuth app's claim configuration in the portal before you debug your request.
Which SOURCE the screen uses is a separate question, and there the answer really is one or the other. connect renders either the resolved claim set or your service's own request catalog, never a blend of the two: the claims source is used when the resolved set is non-empty, and the catalog only when it is empty — i.e. when your app declares nothing and your request names nothing, which is what an integration that has never touched claims gets. One further way to land on the catalog in connect: a claim is only rendered here if it carries the 1:1 stay-connected binding to one of your service's request rows, since that is the slot the live connection is keyed by. If none of the resolved claims has one, the screen falls back to the catalog rather than refusing.
Requesting fields — claims
Add a URL-encoded JSON array claims. A claim is a request field: you say what you
need — a name, a field type, optionally a suggested slug, whether it is
required, and whether only a verified value will do — and the person
decides which of their own fields answers it. You never name one of their fields.
[
{ "name": "email", "type": "email", "suggest": "email_personal", "required": true, "verified": true },
{ "name": "phone", "type": "phone" },
{ "name": "shipping", "type": "address", "label": "Delivery address" }
]
The consent screen shows one row per resolved claim (see above — your request's claims combined with the ones your company declared), pre-selecting the suggested field and letting the person pick another same-type field or type a value. Binary types (photo, document) are not claimable. The SDKs build and encode this for you.
name is required, and it is your key for everything that comes back.
values and values_attestation are keyed by it, and so is the person's stored choice for a
repeat login. Two claims may not share a name — the request is rejected rather than one answer
silently overwriting the other. For the standard OIDC scopes the names are the standard claim names
(email, name).verified — "only a verified value will do". The person still sees their unverified
fields (hiding them would look like they have none), but selecting one is refused on the spot with a prompt to
verify it, and typing a value is not offered at all. Two limits, both refused loudly at authorize with
error=invalid_request rather than quietly ignored: verified is not available on the
one_time leg (it carries no source-field reference, so allme can neither enforce nor attest it), and
only field types allme can verify accept it — data the platform's admin panel controls, currently email, phone,
and the name/date-of-birth/document-number attributes an identity-document or address check establishes. A
binary identity-document value is excluded — it can never travel as an inline claim.
If your integration cannot send claims at all — a standards-only OIDC library driving
scope=openid email profile — you can still get everything below: configure it per claim on the app
in the allus portal, beside the redirect URIs.
required, verified, an optional hint (the sentence the person reads
saying why you need it), the 1:1 stay-connected field binding, and
mandatory_connected — the person must keep that field connected; a one-off value is not an
answer. The two standard OIDC claims email and name keep required,
verified and their binding only: their type is fixed in the portal, because your library
expects those claim names in the id_token exactly as the spec defines them — a request that sends
its own claims still declares type, suggest and label per
transaction, exactly as before. For the same reason a claim you declare yourself may not be named
email or name: it would key the same configuration entry and replace the
standard one, so it is refused.
Claim names are 1–40 characters of a-z, 0-9 and _, may not be a
registered JWT/OIDC claim (sub, aud, exp, …), and are taken
verbatim — a name with surrounding whitespace is refused rather than tidied up, because
the name is an identity your integration keys off.
If you read and write this config from your own automation: a declared claim carries
type, required, mandatory_connected, verified,
service_field_id and hint; a standard email/name entry
carries only required, verified and service_field_id. Those are the same
shapes the API returns, so you can write back what you read, unchanged. Adding the other three
fields to a standard claim is refused rather than ignored, so a setting that could never take effect is never
silently accepted.
The registered config is a FLOOR. A request that DOES send claims may make them
stricter — turning required or verified on — and can never turn them off:
what your admin configured in the portal is not something your code can silently undo.
mandatory_connected, the hint and the binding are portal-only and a request cannot express them
at all.
Two limits worth knowing before you configure one. A mandatory_connected
claim, and any verified claim, cannot be honoured on the one_time leg — that leg
delivers a value frozen at consent and carries no reference to the field it came from — so a sign-in in that
mode fails with invalid_request rather than quietly downgrading. And a claim you declare
yourself needs claims_delivery = encrypted: under plaintext delivery only the
standard email/name claims can travel, because they are the only ones the
id_token can carry.
Plugin claims
A claim you declare may instead be answered through a plugin your company added in the portal — a field the person fills in by searching and picking from options a plugin serves, with outputs the plugin computes (Building plugins). You declare it on the app in the portal (plugin, field type, which of your other claims feed its inputs, required, hint). In the app's claim config it reads:
{ "wage_scale": { "type": "plugin", "required": true, "hint": "To calculate your wage",
"plugin_spec": { "plugin_id": "019…", "field_type": "cao",
"inputs": { "age_at_start": "age" } } } }
- Encrypted delivery only. An app delivering
plaintextcannot save a plugin claim. A plugin claim takes no stay-connected binding, nomandatory_connectedand noverified; each input names another claim of the same app, of a matching type, and the plugin claims' inputs may not form a cycle. Anything else is refused withoauth_apps.claim_config_plugin_invalid, the reason inerror. The spec'ssnapshotis refreshed from the plugin's current description on every save. - Always one-time. The answer is frozen at sign-in and sealed to your app key; it is never a profile field, never "stay connected" and never a remembered choice — so a sign-in whose app declares a plugin claim always shows the consent screen.
- Declared by the company, never requested. A request whose
claimsnames a declared plugin claim, or asks for typeplugin, is refused withinvalid_request. - Not in
connectmode. A plugin claim has no binding, so theconnectscreen does not render it. A plugin request row of your service, on the catalog source, is answered there like any catalog row and reaches you as a one-time value over the service data API. - An account under parental supervision cannot sign in to share data claims, plugin claims included.
The value arrives in values[name] like any other claim, decrypted by the SDK to the plugin answer's JSON string — {"plugin", "type", "blocks", "outputs"}. Read it with parsePluginValue:
from allus_company_data import parse_plugin_value
scale = parse_plugin_value(result["values"]["wage_scale"])
for o in scale.outputs:
print(o["label"], o["value"]) # Minimum wage 9.5 …import { parsePluginValue } from '@allus-fyi/company-data';
const scale = parsePluginValue(result.values.wage_scale as string);
for (const o of scale.outputs) console.log(o.label, o.value); // Minimum wage 9.5 …use Allus\CompanyData\OAuthClient;
$scale = OAuthClient::parsePluginValue($result['values']['wage_scale']);
foreach ($scale->outputs as $o) {
echo $o['label'], ' ', $o['value'], PHP_EOL; // Minimum wage 9.5 …
}scale, err := companydata.ParsePluginValue(result.Values["wage_scale"])
if err != nil {
log.Fatal(err)
}
for _, o := range scale.Outputs {
fmt.Println(o.Label, o.Value) // Minimum wage 9.5 …
}var scale = OAuthClient.ParsePluginValue(result.Values["wage_scale"]);
foreach (var o in scale.Outputs)
Console.WriteLine($"{o.Label} {o.Value}"); // Minimum wage 9.5 …PluginValue scale = OAuthClient.parsePluginValue(result.values().get("wage_scale"));
for (PluginValue.Output o : scale.outputs()) {
System.out.println(o.label() + " " + o.value()); // Minimum wage 9.5 …
}3. Receive the code & read the identity
redirect: the person lands on redirect_uri?code=…&state=…. detached: poll for it:
# detached: poll until the person approves on their phone
curl -s -X POST https://api-eu.allme.fyi/oauth2/result \
-d client_id=idw_... -d client_secret=... # secret only for confidential apps
-d state=RANDOM
# → 202 (pending) … then → { "code": "def502...", "state": "RANDOM" } (once)
The poll answers 401 with error_key: "oauth.invalid_client" for an unknown or deleted app and for a wrong secret alike, and 400 with error_key: "oauth.invalid_request" when state is missing. The SDKs' poll_result raises either as ApiError.
A 503 with error_key: "db.writes_paused" on this poll means saving is paused on the platform: nothing was written and the result was not consumed, so wait Retry-After (30 seconds) and poll again. The SDKs' poll_result raises it as ApiError rather than retrying. The code exchange at /oauth2/token keeps working meanwhile.
A 503 with error_key: "platform.out_of_order" means allme is temporarily out of order: the request was not processed and the result was not consumed, so wait Retry-After (300 seconds) and poll again. The code exchange at /oauth2/token and userinfo answer the same 503 meanwhile. The SDKs raise it as ApiError rather than retrying.
Exchange the code at /oauth2/token (standard authorization-code, with your PKCE verifier or secret). The token response adds mode. Then call userinfo with that token:
curl -s https://api-eu.allme.fyi/api/oauth/userinfo -H "Authorization: Bearer ey..."
# → { "sub": "2I6UF3", "share_code": "2I6UF3",
# "mode": "one_time", "two_factor": true,
# "values": { "email": "{\"_enc\":1,...}", "phone": "{\"_enc\":1,...}" },
# "values_attestation": { "email": "{\"_enc\":1,...}" } }
values is present for one_time, for connect and for an OIDC login on an encrypted-delivery app; each value is ciphertext encrypted to your app's public key — decrypt it with the private key you downloaded (RSA-OAEP-SHA256 + AES-256-GCM). The values live only as long as the short-lived token; read them promptly. The person's own field id is never exposed — values are keyed by the name you gave the claim — and when connect shows your service's request catalog instead, by the slug of the request field, never by an internal row id.
sub is the share code, and there is no display_name.
sub and share_code are the same value, and sub is byte-identical to the one in
the id_token — so the standard "userinfo sub must match the id_token's" check passes. To get
the person's name, ask for it as a claim:
Claim(name="name", type="text"), then read values["name"]. The person chooses which of
their own text fields answers it, so it is their choice rather than a value you get unasked.values_attestation — proving a value is verified
When you ask for a claim with verified: true and the app delivers encrypted, userinfo
carries an additive sibling map values_attestation, keyed by the same claim name as
values. Each entry is a second {"_enc":1,…} object sealed to your app key; decrypt it the
same way and you get {"hash", "salt", "verified_at", "verified_expires_at"}, plus the proof trio
{"verified_method", "verified_provider", "verification_id"} described below. Check
sha256(salt ‖ plaintext) == hash against the value you just decrypted — lowercase hex,
and compare in constant time. The SDKs do all of this for you and hand you a computed
verified boolean (see the quickstart below).
- A claim ABSENT from
values_attestationis "not attested" — never "wrong". Treat that value as unverified. That is what you see for a plaintext-delivery app, for a claim you did not markverified, and on the wholeone_timeleg. - A mismatch means you MUST reject the value. It is the substitution this exists to detect — do not log it and continue.
verified_atis a snapshot, not a live guarantee. It attests that the value was verified AT THAT MOMENT. A field loses its verification whenever the person re-saves it, so a{value, hash, salt}triple you stored at login stays self-consistent forever and does not tell you the address is still verified today.
allme builds and seals this server-side — a client-supplied attestation is never accepted — so it attests allme's own record of the field the person chose.
The seal also says HOW the value was proved, in three members that arrive together or not at all:
verified_method (email_code, sms_code, sumsub_id or
sumsub_address), verified_provider (allme for a code allme itself sent,
sumsub for an identity or address check) and verification_id — the proof id
you quote back to allme when a value is disputed, which resolves the full record of that verification act. A value
verified before this record existed seals the first four members and none of these three. They are additive
inside the seal, so an older SDK still opens it, and they are informational: the hash recompute above stays
the only trust decision.
The SDK quickstart
Each SDK ships an OAuth module that builds the URL, exchanges the code, and decrypts the claim values from config — you never touch a key in code.
from allus_company_data import OAuthClient, Claim
oauth = OAuthClient.from_config("idw.json") # client_id, redirect_uri, private key + passphrase
# 1) the button URL (checkout wants an email once)
url = oauth.authorize_url(mode="one_time", claims=[Claim("email", "email", suggest="email_personal", required=True)], state=state)
# 2) on your callback (or after poll_result for detached):
result = oauth.complete_sign_in(code, code_verifier=verifier)
print(result["user"]["sub"], result["mode"], result["values"]["email"]) # decrypted
# the ciphertext it came from is result["values_cipher"]["email"]
# a verified claim also lands in result["attestations"]["email"].verifiedimport { OAuthClient, Claim } from "@allus-fyi/company-data";
const oauth = OAuthClient.fromConfig("idw.json");
const url = oauth.authorizeUrl("one_time", { claims: [{ name: "email", type: "email", suggest: "email_personal", required: true }], state });
// on your callback:
const result = await oauth.completeSignIn(code, { codeVerifier });
console.log(result.user.sub, result.mode, result.values.email);
// the ciphertext it came from is result.values_cipher.email
// a verified claim also lands in result.attestations.email.verifieduse Allus\CompanyData\OAuthClient;
use Allus\CompanyData\Claim;
$oauth = OAuthClient::fromConfig('idw.json');
$url = $oauth->authorizeUrl('one_time', [new Claim('email', 'email', suggest: 'email_personal', required: true)], state: $state);
// on your callback:
$result = $oauth->completeSignIn($code, codeVerifier: $verifier);
echo $result['user']['sub'], $result['mode'], $result['values']['email'];
// the ciphertext it came from is $result['values_cipher']['email']
// a verified claim also lands in $result['attestations']['email']['verified']import (
"fmt"
"log"
"github.com/allus-fyi/company-data-go/companydata"
)
oauth, err := companydata.OAuthClientFromConfig("idw.json") // client_id, redirect_uri, private key + passphrase
if err != nil {
log.Fatal(err)
}
// 1) the button URL (checkout wants an email once)
authURL, err := oauth.AuthorizeURL("one_time", &companydata.AuthorizeURLOptions{
Claims: []companydata.Claim{{Name: "email", Type: "email", Suggest: "email_personal", Required: true}},
State: state,
})
if err != nil {
log.Fatal(err)
}
fmt.Println("send the user to:", authURL)
// 2) on your callback (or after PollResult for detached):
result, err := oauth.CompleteSignIn(code, verifier)
if err != nil {
log.Fatal(err)
}
fmt.Println(result.User["sub"], result.Mode, result.Values["email"]) // decrypted
// the ciphertext it came from is result.ValuesCipher["email"]
// a verified claim also lands in result.Attestations["email"].Verifiedusing Allus.CompanyData;
var oauth = OAuthClient.FromConfig("idw.json");
// 1) the button URL (checkout wants an email once)
var url = oauth.AuthorizeUrl("one_time",
claims: new[] { new Claim("email", "email", Suggest: "email_personal", Required: true) },
state: state);
// 2) on your callback (or after PollResultAsync for detached):
var result = await oauth.CompleteSignInAsync(code, codeVerifier);
Console.WriteLine($"{result.Sub} {result.Mode} {result.Values["email"]}"); // decrypted
// the ciphertext it came from is result.ValuesCipher["email"]
// a verified claim also lands in result.Attestations["email"].Verifiedimport fyi.allme.allus.companydata.OAuthClient;
OAuthClient oauth = OAuthClient.fromConfig("idw.json");
// 1) the button URL (checkout wants an email once)
String url = oauth.authorizeUrl("one_time", new OAuthClient.AuthorizeOptions()
.claims(List.of(new OAuthClient.Claim("email", "email", "email_personal", true, false, null)))
.state(state));
// 2) on your callback (or after pollResult for detached):
OAuthClient.SignInResult result = oauth.completeSignIn(code, verifier);
System.out.println(result.user().get("sub") + " " + result.mode()
+ " " + result.values().get("email")); // decrypted
// the ciphertext it came from is result.valuesCipher().get("email")
// a verified claim also lands in result.attestations().get("email").verified()For detached, use poll_result(state) instead of a callback — it loops the /oauth2/result poll and returns the code.
The progressive sign-up pattern
Start every returning user with signin. If they are new (your backend doesn't recognise the sub), immediately send them through again with one_time claims to collect what you need for sign-up — the second consent is one tap because they are already signed in. One button, no separate “register” form.
<a href> — not a JavaScript window.location redirect. So make the progressive second step a real button/link the person taps, not an automatic redirect, or iOS will open the web fallback instead of the app.Security notes
- Redirect URIs are exact-match. Register every callback; there are no wildcards. A mismatch is rejected before any code is minted.
- Always verify
stateon return — it is your CSRF binding between the button click and the callback. - Public apps must use PKCE (
code_challenge/code_verifier, S256). Confidential apps authenticate the token exchange with their secret. - Require 2FA can be set on the app or the referenced service; it is enforced server-side when the code is minted, and the result carries a
two_factorattestation inuserinfo— a biometric unlock on the phone satisfies it. - Zero-knowledge.
one_timevalues are encrypted to your app key on the person's device; the platform stores only ciphertext and forgets it when the short-lived token expires.