VisualEyes as a SAML 2.0 identity provider
If your app speaks SAML 2.0 — Nextcloud, Shibboleth-era SaaS, enterprise portals and most older intranet platforms do — you can offer “Sign in with VisualEyes” without writing any code. Point the app at our metadata URL, register your Assertion Consumer Service URL, map one attribute, and your users sign in by recognising their own photos instead of typing a password.
VisualEyes is a SAML 2.0 Identity Provider supporting the Web Browser SSO profile, SP-initiated only. It runs alongside our OpenID Connect provider — same photo-login core, same pairwise identity per user — so the legacy tier integrates by configuration too. If your app supports both protocols, use OIDC: it is simpler, has no XML, and needs no certificate handling.
- Register your site at /client/register (email +
one-time code). You get a
client_idand asecret— the same credentials used by the client API and the OIDC facade; one registration covers all of them. - Find your app’s SAML details. Every SP publishes (or documents) two values: its EntityID and its Assertion Consumer Service (ACS) URL. They are usually in the SP’s own metadata document.
- Enable SAML in your dashboard: switch it on, paste your
SP’s EntityID, and add your ACS URL. ACS URLs are matched
character-for-character and must be
https(http://localhostandhttp://127.0.0.1are allowed for development). - Configure your app with:
IdP metadata URL: https://aqa.com/saml/metadata IdP EntityID: https://aqa.com SSO URL: https://aqa.com/saml/sso (HTTP-Redirect or HTTP-POST) NameID format: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent Signing cert: the X.509 certificate published in our metadata Account mapping: the ve_uid attribute (see Identity below)
- Test it. Start a login from your app: the browser goes to our hosted sign-in gate, the user types their VisualEyes name and solves their photo challenge, and a signed assertion is POSTed back to your ACS.
SP-initiated Web Browser SSO — your app never sees photos or secrets, only standard SAML artifacts:
- Your app sends the browser to
/saml/ssowith anAuthnRequest(HTTP-Redirect or HTTP-POST binding) and, optionally,RelayState. - We check the request: your EntityID must be registered and enabled, and the
AssertionConsumerServiceURLmust be on your exact-match allow-list. Until both are proven, nothing is ever sent to an address the request supplied — a bad request renders a local error page here, never a redirect. - We show the VisualEyes gate, run the user’s photo-recognition challenge — including all of our risk step-up, lockout and duress machinery.
- We build a
Responsecontaining oneAssertion, sign both, and auto-POST it to your registered ACS URL over the HTTP-POST binding, with yourRelayStateechoed back byte-for-byte.
If the user declines at the gate, or the challenge fails, you get a signed error Response at the same ACS rather than a dangling browser — so your app can end the attempt cleanly.
| Item | Value |
|---|---|
| Metadata | https://aqa.com/saml/metadataserved as application/samlmetadata+xml |
| Single Sign-On | https://aqa.com/saml/ssoHTTP-Redirect and HTTP-POST bindings |
| EntityID / Issuer | https://aqa.com |
| NameID format | persistent |
| Response binding | HTTP-POST to your registered ACS |
| Signing | rsa-sha256, exclusive C14N,
sha256 digests — both the Response and the
Assertion are signed |
| AuthnContextClassRef | urn:vercet:visualeyes:photo |
| Assertion validity | 5 minutes, with 30 seconds of clock skew |
Fetch the signing certificate from our metadata rather than
hard-coding it. When we rotate a key, both the old and the new certificate are listed
as use="signing" throughout the overlap window, so an SP that refreshes
its metadata cache rotates with no downtime. Metadata is served over HTTPS and is
deliberately unsigned — the transport is the trust anchor, exactly as for OIDC
discovery.
ve_uidThe NameID we issue is a persistent, pairwise subject: deterministic
for a given person at your service provider, different at every other
VisualEyes-connected SP, and never the email address. Services cannot correlate
users with each other. It is also the same subject the
OIDC facade issues for the same VisualEyes customer
account — so a customer running both protocols, or migrating from one to the other,
keeps one stable identity per user.
We also emit that identical value as an attribute named ve_uid, and
that is the mapping we recommend you use. Many SAML products resolve the local
account strictly from an attribute and never read the NameID at all —
Nextcloud’s user_saml is exactly this, with no NameID
fallback. Without ve_uid such an SP could only key on email,
which needs the email grant and throws away the uncorrelatable identity the product
exists to provide. ve_uid and the NameID always carry the
same value, so mapping either one gives you the same stable account key.
| Attribute | Meaning |
|---|---|
ve_uid | Always present. The pairwise, persistent
subject — identical to the NameID. Key your accounts on it. |
ve_duress | Always present, "true" or
"false". See the warning below. |
ve_alias | Present when the site uses per-site aliases: the alias the user signed in with — sign in the matching local account. |
email | Only when your SP has been granted the email
attribute (a per-SP setting in your dashboard). Prefer
ve_uid; request email only if your app genuinely needs it. |
Attribute values are emitted untyped (no xsi:type) with
NameFormat basic, which every mainstream SP accepts.
⚠ ve_duress is server-side only — never show it in any UI
If ve_duress is true, the user completed their login with a
panic photo — a silent alarm that they may be acting under coercion. Act on it
server-side only: show a completely normal session while limiting sensitive
actions, alerting your security team, or whatever your duress playbook says. Anything
visible in the browser — a banner, a different page, even a subtly different response
time you can avoid — can endanger the person standing next to your user. Never surface
the attribute in an admin screen, a profile field or a log your users can read.
VisualEyes also runs its own out-of-band escalation independently of your app.
VisualEyes holds no login session. That single decision — the same one behind the OIDC facade — removes three things a session-based IdP would offer. They are security properties, not gaps, and they are not on a roadmap:
- No Single Logout (SLO). There is no IdP-side session to end, so there is nothing for an SLO endpoint to terminate. Ending your own application session is the whole of logout.
- No IdP-initiated SSO. Every login starts at your service provider with an
AuthnRequest. Unsolicited responses are never produced — which also removes the classic unsolicited-assertion attack surface. - Every sign-in is a fresh photo challenge.
ForceAuthnis effectively always true — asking for it changes nothing, because it is already what happens.IsPassiveis refused with a signed error Response carryingResponder/NoPassive: silent re-authentication can never succeed here, because proving it with photos every time is the product.
Also not implemented: assertion encryption, the HTTP-Artifact binding, ECP, metadata-federation ingest and signed metadata. Tell us if one of these genuinely blocks you.
- EntityID. Use your SP’s real EntityID exactly as it appears in the
Issuerof theAuthnRequestit sends. An unknown, disabled or revoked EntityID is refused identically — there is no oracle telling a prober which it was. - ACS URLs are an exact-match allow-list. Register every ACS URL your app uses,
character-for-character including path and any trailing slash. We never
wildcard-match. They must be
https;http://localhostandhttp://127.0.0.1are permitted for development only. If anAuthnRequestcarries noAssertionConsumerServiceURL, we use the first URL you registered; if it carries one that is not on the list, the request is refused before anything is sent anywhere. - Signed AuthnRequests are optional. Unsigned requests are accepted by default —
the ACS allow-list is the real control. An SP that signs can opt into
require_signed_requests, which requires its certificate and enforces the HTTP-Redirect binding’s detached query-string signature. Onlyrsa-sha256is accepted;rsa-sha1is refused, never downgraded. A strict SP must therefore use the HTTP-Redirect binding: we do not verify XML-DSig on inbound documents at all, by design. - ProtocolBinding. If your
AuthnRequestsets one, it must be HTTP-POST — that is how responses are delivered. - RelayState is echoed back byte-for-byte. Keep it within the SAML binding spec’s 80 bytes; an oversized value is dropped rather than carried, so your app would lose its state. Store the real state on your side and pass a short key.
- Keep your clocks synced. Assertions are valid for 5 minutes with 30 seconds of skew allowance. An SP whose clock has drifted will reject perfectly good assertions — run NTP.
- A sign-in request at the gate expires after 10 minutes, and is single-use: a replayed callback cannot produce a second assertion or a second charge.
Verified end-to-end against Nextcloud 34.0.2 with the SSO & SAML
authentication app (user_saml) 8.2.0: Nextcloud’s own
AuthnRequest → our gate → photo challenge → signed assertion
→ its ACS accepts it and establishes a session, with the Nextcloud account keyed on
the pairwise subject rather than the user’s email.
1. Register Nextcloud with us
Unless you have changed them, Nextcloud’s SP values are:
SP EntityID: https://<your-nextcloud>/apps/user_saml/saml/metadata SP ACS URL: https://<your-nextcloud>/apps/user_saml/saml/acs
Put both into the SAML card in your dashboard — the ACS exactly as written, with no trailing slash.
2. Configure Nextcloud
Use the occ command line: it is the reliable path, and it is the only one
that can set every field in one go. Copy the certificate body out of
https://aqa.com/saml/metadata (the text inside
<ds:X509Certificate>, without the PEM header and footer lines).
CERT="MIICvjCCAaagAwIBAgIU...<the X509Certificate from our metadata>...==" # create a provider and note the numeric id it prints occ saml:config:create occ saml:config:set 1 \ --general-idp0_display_name="VisualEyes" \ --general-uid_mapping="ve_uid" \ --idp-entityId="https://aqa.com" \ --idp-singleSignOnService.url="https://aqa.com/saml/sso" \ --idp-x509cert="$CERT" \ --security-authnRequestsSigned=0 \ --security-wantAssertionsSigned=1 \ --security-wantXMLValidation=1 occ saml:config:validate
On a containerised install, prefix these with your runtime’s exec command and run
them as the web user — for example
podman exec -u www-data <container> php occ ....
3. The two settings that matter
--general-uid_mapping="ve_uid"is required. Leaving it empty does not fall back to theNameID—user_samlresolves the account strictly from the mapped attribute, so an empty mapping means login fails outright. Setting it tove_uidgives Nextcloud the privacy-preserving pairwise identifier and avoids needing the email grant at all.--security-wantAssertionsSigned=1— Nextcloud requires signed assertions. We sign both the Response and the Assertion, so this is satisfied; you can also set--security-wantMessagesSigned=1if you want Nextcloud to insist on the outer signature too.
⚠ occ config:app:set user_saml … will silently do nothing
user_saml 8.x stores each provider’s configuration in its own
database table, not in the app-config store. Writing keys like
idp-entityId with occ config:app:set reports success and has
no effect whatsoever — the login then fails with no obvious cause. The
occ saml:config:create / saml:config:set /
saml:config:validate commands are the correct path.
Other service providers
Shibboleth SP, simpleSAMLphp, and most enterprise SaaS admin panels ask for the same
handful of values: our metadata URL (or EntityID + SSO URL + signing certificate),
the persistent NameID format, and an attribute to key accounts on — plus registering
their ACS URL with us. Map accounts to ve_uid wherever an attribute mapping
is offered.
- Each successful SAML sign-in costs the same as any VisualEyes login — 15 credits (~1.5¢), debited from your existing balance. Failed, refused or abandoned sign-ins cost nothing. Your dashboard ledger shows SAML entries by the user’s pairwise ID, never by email.
- If your balance can’t cover a login, the request is refused in-protocol with a signed error Response before the sign-in page is ever rendered — top up at /dash.
- Sign-in requests expire after 10 minutes; assertions are valid for 5 minutes with 30 seconds of clock skew.
- Validate the signature on every assertion against the certificate published
in our metadata, and reject anything unsigned. Both the
Responseand theAssertionare signed withrsa-sha256— pin the algorithm and never accept SHA-1 or an unsigned document. - Load the certificate from our metadata and refresh it periodically. Both certificates are published during a rotation overlap, so a client that re-reads metadata rotates seamlessly; one that hard-codes a certificate will break.
- Check
Issuerishttps://aqa.com,Audienceis your EntityID,Destinationand the bearerRecipientare your ACS URL, andInResponseTomatches anAuthnRequestyour app actually sent. Any decent SAML library does all of this — confirm yours does rather than assuming. - Enforce
NotBefore/NotOnOrAfterand keep NTP running. - Register the exact ACS URLs your app uses, and remove ones you no longer need.
- Key accounts on
ve_uid(or, equivalently, theNameID) — never onemail, and never on both at once: an SP that keys on one and later switches to the other will silently split one person into two accounts. - If
ve_aliasis present, use it to select the local account; treatemailas informational. - Have a
ve_duressplaybook before you go live — and keep it entirely server-side (see the warning above). - If your SP signs its
AuthnRequests, turn onrequire_signed_requestsand use the HTTP-Redirect binding, so an unsigned request in your name is refused.
The facade implements SAML 2.0 Web Browser SSO, SP-initiated, and is verified by tools we did not write:
- xmlsec1 — the reference implementation underneath python3-saml,
simpleSAMLphp and Shibboleth — verifies both of our signatures, and correctly rejects
an assertion whose
NameIDhas been tampered with (the negative control that makes the pass meaningful). - Our
Responsevalidates against the official OASIS SAML 2.0 protocol XSD, and our metadata against the metadata XSD. - A real product — Nextcloud with
user_saml— completes a full SP-initiated login end-to-end, with the account keyed on our pairwise subject.
On the inbound side we take a deliberately narrow stance: we only ever produce
XML signatures, over XML we generate ourselves, and we never verify XML-DSig on an
untrusted document — which removes signature-wrapping as a class of attack. Inbound
AuthnRequests are parsed with DTDs, entity declarations, processing
instructions and external references hard-rejected (XXE), under strict size caps before
and after decompression (compression bombs).
Two design choices differ from a conventional session-based IdP — no session, and therefore no SLO, no IdP-initiated SSO and no silent re-authentication. Both are covered above; both are permanent, and both exist so that every sign-in is a fresh recognition.
Speaks OIDC instead? Use the OpenID Connect guide — it is the simpler integration wherever your platform supports both. Questions, or a platform that won’t cooperate? hello@aqa.com — we’ll help you wire it up.