Technical specifications

Legal.id

1 · System

One issuer, many relying parties.

An OpenID Connect provider operated by Lextronica, Inc. Relying parties — Jane first — integrate over standard OIDC, no proprietary SDK. Applications never see credentials, only signed tokens carrying consented claims. Every identity also publishes a did:web document that anything can resolve without an account here.

EndpointPathNotes
Discovery/.well-known/openid-configurationCanonical metadata; everything below is advertised here
Authorization/oauth/authorizeBrowser redirect; consent screen for third-party clients
Token/oauth/tokenCode exchange + refresh; client secret over TLS
UserInfo/oauth/userinfoBearer access token; same claim set as the ID token
JWKS/oauth/discovery/keysRSA public key(s), kid-tagged
Revocation/oauth/revokeRFC 7009; client-authenticated
End session/logout/oidcRP-initiated logout; validated post_logout_redirect_uri
DID document/u/{handle}/did.jsondid:web resolution target
Attestation/u/{handle}/attestation.jwtFresh EdDSA-signed JWS of the identity's public facts
Verify (human)/verify?u={handle}Public lookup page; performs and displays the JWS check

2 · Protocol

OAuth 2.0 + OIDC, under OAuth 2.1 rules.

How clients connect

Grant typesauthorization_code + refresh only. No implicit, no password grant, no client credentials.
PKCERequired (S256), enforced server-side. Confidential clients send it too.
ClientsConfidential only, operator-registered. Exact redirect-URI matching; HTTPS outside development.
State / nonceRound-tripped and validated. The nonce is embedded in the ID token.
ConsentPer-scope consent screen for third-party clients; first-party clients are pre-authorized.

What the server issues

Authorization codeSingle use · expires in 60 seconds
Access tokenOpaque (not a JWT) · 2 hours · revocable
Refresh tokenRotated on every use; the replaced token is invalidated
ID tokenJWT, RS256, kid in header · 2 hours · aud = client id, iss = https://legal.id
Signing keyRSA-2048, published at JWKS. Rotation is a deploy procedure: the new key issues immediately, old tokens age out within the 2-hour TTL. Multi-key JWKS is a follow-up.
SubjectOpaque stable UUID (sub), never the handle, so handles can change without breaking RP links

Scopes and the claims they release

ScopeClaims released (ID token + UserInfo)
openidsub (opaque UUID), standard JWT claims (iss, aud, exp, iat, nonce, auth_time)
emailemail, email_verified (boolean — RPs are instructed not to link accounts when false)
profilename, given_name, middle_name, family_name, preferred_username (the handle), locale, zoneinfo, birthdate (YYYY-MM-DD), legal.id/age_over_18 (boolean), legal.id/tier, legal.id/did
verificationsverified_claims (OpenID Connect for Identity Assurance 1.0): an identity check, with the evidence type, document type, check methods, time and the name as it appeared on the document. The issuing country is left out. legal.id/affiliations: {organization, domain, method, verified_at, expires_at} for each work email proven in the last 90 days. Both omitted when there's nothing verified.
addressaddress (OIDC Core): locality, region, country — where the person lives. Sensitive.
nationalitynationalities (OpenID Identity Assurance claims registration): every country the person is a citizen of. Sensitive.
birthplace_of_birth (country, locality), birth_given_name, birth_family_name (OpenID Identity Assurance). Sensitive.
residence_statuslegal.id/residence_status: {country, status, visa_type}, status one of citizen, permanent_resident, visa, other. No standard claim exists for immigration status. Sensitive.

Everything above except sub, email_verified, legal.id/tier, legal.id/did and the verifications scope is self-declared and not verified, and none of it affects legal.id/tier. Claims are omitted when the person hasn't provided them; an absent claim means "not provided", never false. Country codes are ISO 3166-1 alpha-2 throughout (the Identity Assurance registration allows two-letter codes for compatibility; it matches address.country and the EU wallet PID). A birthday can be set once; users under 13 cannot record one.

Sensitive scopes are never part of profile: an app must name them. The person sees each one, with its value, on the consent screen the first time an app asks — first-party apps included — and can untick it. A sensitive scope the person hasn't filled in is not granted, so nothing reaches an app unseen. Consent is remembered until the person revokes the app. When a verified source exists (for example a nationality presented from an EU Digital Identity Wallet), it will travel in verified_claims per OpenID Identity Assurance, alongside the self-declared value.

3 · Assurance

Tiers state what was verified.

Each tier names the verification that produced it and travels in the token as legal.id/tier. Relying parties make their own trust decisions.

TierVerification performedRecorded
DigitalControl of the email address proven via confirmation linkemail_verified_at
CoreIdentity corroborated by sign-in with a verified Google accountgoogle_verified_at + provider uid
VerifiedAn organization checked a government ID against the person, in person or on a live video call. Today that's Legal.io, for its own people.A verifications row: document type and issuing country, how it was checked, date, name as shown, and the admin who recorded it. Document images and numbers are never stored or entered.

Verifications

Two kinds, shaped after OpenID Connect for Identity Assurance. An identity check is recorded once by an administrator and doesn't expire; revoking it drops the account to the tier its other verifications support. A work email is proven by signing in with an organization's Google Workspace account and lapses 90 days after the last such sign-in, so someone who leaves stops showing it. Apps asking for the verifications scope receive verified_claims and legal.id/affiliations. Every change writes an audit event.

Unverified accounts

An account with an unconfirmed email is Unverified: the card renders, but tokens carry email_verified: false and relying parties are told not to link accounts on it.

4 · Identity

did:web.

Resolution

did:web:legal.id:u:{handle}  ⇩ resolves tohttps://legal.id/u/{handle}/did.json  ⇩ publishesEd25519 key (publicKeyMultibase, z6Mk…)profile + verification service endpoints

Path form, with an upgrade path to did:webvh. Key custody is server-side today, private keys encrypted at rest. Holder-controlled keys arrive with the wallet.

Attestation (JWS)

GET /u/{handle}/attestation.jwt  ⇩ compact JWS, alg=EdDSAheader: { alg, typ, kid: {did}#key-1 }payload: { iss, sub: {did}, handle,  legal.id/tier, legal.id/issued,  iat, exp: iat+600 }  ⇩ verify againstdid.json → Ed25519 public key

Ten-minute validity: a timestamped assertion of current status, not a bearer credential. Wallet-held credentials (OpenID4VCI/VP, selective disclosure) are next, and not claimed until they ship.

5 · Operations

Sessions, limits, and what we store.

Holding a session

Sign-in methodsPasskeys (WebAuthn, discoverable credentials, counter-checked) · password (min 10 chars, bcrypt) with mandatory email verification · optional Google corroboration
Session storeServer-side records (IP + user agent), signed httponly cookie, SameSite=Lax, Secure in production
Lifetime30 days absolute · 14 days idle · users see all active sessions and can revoke any, or all others at once
Fixation defenseRails session reset on every privilege transition

Ending a session

RP-initiated logoutend_session_endpoint in discovery. Ends the IdP session, redirects only to origins registered by a known client, round-trips state.
App revocationRevoking an app from Settings invalidates its tokens and grants immediately. Clients can revoke via RFC 7009.
RecoveryPassword reset by email, 30-minute single-use token. Completing a reset invalidates all other sessions. Responses are generic, so accounts cannot be enumerated.
AuditAppend-only: sign-ins and failures, sign-outs, token issue/refresh/revocation, consents, passkey add/remove/failed attempts, verification changes, attestations. Recent events are shown to the user. Write failures alarm rather than drop silently.

Rate limits

SurfaceLimit
Password login10/min per IP · 6 per 5 min per email
Signup8 per 5 min per IP. Invite redemption is atomic and capped
Token endpoint30/min per IP
Password reset5 per 15 min per IP
Passkey endpoints30/min per IP

TLS is forced (HSTS). All responses send frame-ancestors 'none'.

What we hold, what RPs get

Stored: email, handle, display name, credential material (password hash, passkey public keys, Google uid), an Ed25519 DID keypair with the private key encrypted at rest, security logs. Never stored: plaintext passwords, identity document images or numbers, SSNs. Relying parties receive only consented claims. Retention and rights are in the privacy policy and terms.

6 · Where this is going

From an identity we host to one you hold.

Each step uses a published standard.

Today

Hosted keys

Legal.id holds each identity's Ed25519 signing key, encrypted at rest, and publishes it as did:web. You can already take your data with you (Settings → Your data) and revoke any app.

Next

Keys you hold

Move to did:webvh: a signed, verifiable history of every key change, with pre-rotation. The update key lives in your passkey or wallet, so Legal.id becomes the host and a witness, not the owner.

Then

Credentials that travel

Issue with OpenID4VCI and present with OpenID4VP (both final), as SD-JWT credentials you hold, disclosing only what an app needs. An over-18 proof without the birthday. Accept presentations from EU Digital Identity Wallets.

Get your Legal.id.

Invite only for now.

Create your Legal.id