For implementers. This page is written for a developer building the Customer Area side of the Customer Portal single sign-on (SSO) handoff. It is not written for office staff, and it assumes you write code.
The Customer Area sends one signed form POST. BareBones Ticketing validates the exact bytes and either accepts the assertion or refuses it. This page is the wire contract. Administrator configuration — which services customers see, the 8-hour session, secret rotation — is in Customer Portal.
Anything not listed here is non-conforming: extra fields, missing fields, a different field order, a different encoding, a query-string payload, an audience taken from the request host, or a signing key derived by decoding the shared secret.
Before you start
Get these from the administrator who configured the integration, through a protected channel:
- the Audience value BareBones Ticketing displays;
- the Expected issuer value BareBones Ticketing displays;
- the Customer Area return address;
- the shared secret, displayed once at generation; and
- the installation's HTTPS base address.
You also need a host clock synchronised to Coordinated Universal Time (UTC), a cryptographically secure random number generator, a stable opaque customer identifier, and a provider-verified email address.
Keep the secret, the payload, the signature, the nonce, the subject, and the email out of source control, logs, exception messages, metric labels, traces, build output, and test fixtures.
The request
POST /signin-customer-portal-sso
Content-Type: application/x-www-form-urlencoded
The callback path is fixed. Build the origin from the installation's configured public base address. Never take it from customer input.
The receiver refuses the request before reading the body if any of these is true:
| Rule | Enforced as |
|---|---|
| No query string | Any query string is refused |
| Content type | First token must be application/x-www-form-urlencoded |
| Body size | Content-Length above 8,192 bytes is refused; the endpoint also carries a request size limit of 8,192 bytes |
| No file parts | Any uploaded file is refused |
| Field count | The form must contain exactly two fields |
| Field names | Exactly one sso and exactly one sig, one value each |
sso length |
At most 6,144 characters |
sig length |
Exactly 64 characters |
Use HTTPS. Do not put sso or sig in the URL.
The two body-size rules are enforced at different layers, and they fail
differently. The Content-Length check is part of the endpoint's own refusal
path: an over-declared length is refused as a plain 401 like every other contract
failure. The 8,192-byte request size limit is a transport-layer backstop for a
request that does not declare an honest length — a chunked body, or one whose
Content-Length understates the bytes. Exceeding that limit fails the read at the
framework layer with a size error (HTTP 413), not the uniform 401. This is the
one refusal you can tell apart from the others, and it is deliberate: it only ever
signals "too large", never anything about an identity, grant, or customer. A
conforming sender never approaches 8,192 bytes, so you should not see it.
Inner payload fields
Build one query-style Unicode Transformation Format 8-bit (UTF-8) string with exactly these nine fields, in exactly this order. The receiver compares the decoded field name at each position against a fixed list, so position and name must both match.
| Position | Field | Value |
|---|---|---|
| 1 | v |
The literal 1 |
| 2 | iss |
The Expected issuer value, byte for byte |
| 3 | aud |
The Audience value, byte for byte |
| 4 | iat |
Issue time, UTC Unix seconds, digits only |
| 5 | exp |
Expiry time, UTC Unix seconds, digits only |
| 6 | nonce |
32 fresh random bytes, unpadded Base64url |
| 7 | sub |
Stable opaque customer identifier, 1 to 256 characters, no control characters |
| 8 | email |
Provider-verified email, 3 to 320 characters, no control characters |
| 9 | portal_access |
The lowercase literal true or false |
Duplicate fields, unknown fields, missing fields, and a different order are all
refused. The receiver splits on & and requires exactly nine parts, each
containing exactly one =.
Issuer
iss is the normalised HTTPS origin of the configured Customer Area return
address: scheme, host, and a non-default port if there is one. No path, no query,
no fragment, no trailing slash. Compare byte for byte — the receiver uses an
ordinal string comparison.
Do not derive it from the current page, a proxy header, or a customer-supplied address.
Audience
aud is the installation's configured public base address with the fixed
callback path appended, and any trailing slash removed. The receiver builds it
only from BareBonesTicketing:PublicBaseUrl. It never uses the request host, the
Host header, a forwarded header, or the Customer Area address.
Time
iat and exp are UTC Unix seconds, parsed as unsigned decimal digits. No sign,
no whitespace, no separators.
The receiver compares against its own clock at the moment the request arrives and refuses the assertion unless all four hold:
expis strictly later thaniat;expminusiatis at most 5 minutes;iatis at most 60 seconds ahead of receiver time; andexpis no more than 60 seconds in the past at receiver time.
The last rule is a tolerance for clock skew, not extra lifetime. Set exp in the
future. An assertion whose exp has already passed by more than 60 seconds is
refused.
The 5 minutes is a ceiling, not a retry budget. One browser handoff needs far less.
Nonce
Generate exactly 32 fresh cryptographically random bytes for every assertion,
including every portal_access=false assertion. Encode them as unpadded
Base64url: - for +, _ for /, trailing = removed. The receiver decodes
the value, requires exactly 32 bytes, and re-encodes it — if the re-encoded text
differs from what you sent, the assertion is refused. Padded or standard Base64
therefore fails.
Never reuse a nonce. See Replay for what the receiver stores.
Subject
sub is your opaque stable customer identifier. Keep it stable across email,
name, and subscription changes. Do not use an email address.
The receiver does not store the raw subject. It stores the SHA-256 hash of the
issuer and subject, length-prefixed, as the identity key. Changing sub creates
a different identity. Sending another customer's sub binds to that customer's
ticket history. There is no subject migration in this protocol.
Send the current provider-verified contact address. The receiver validates it as a parseable mail address that round-trips unchanged, then stores it as read-only contact data on the requester.
Email never selects the identity and never transfers ticket ownership. After a later valid handoff the receiver updates the stored address if it differs.
Entitlement
portal_access carries one boolean, and nothing else.
true— this customer may receive or refresh a session.false— this customer must not receive a session, and any current grant is revoked.
Do not send plan, price, payment, subscription, licence, renewal, role, group, department, project, or workflow facts. The receiver does not read them, and any extra field makes the payload non-conforming.
Encoding the payload
For each of the nine name/value pairs:
- Encode the text as UTF-8.
- Percent-encode both the name and the value with RFC 3986 behaviour — the
receiver checks each part against
Uri.EscapeDataString, so your encoder must match it exactly. - Join the encoded name to the encoded value with one
=. - Join the nine pairs with
&.
Do not use + for a space. Do not normalise, reorder, or re-encode after the
string is built. The receiver percent-decodes each part, re-encodes it, and
refuses the assertion if the result differs from what arrived. It also refuses
any raw byte above 0x7F in the encoded text.
The shape, with placeholders:
v=1&iss={issuer}&aud={audience}&iat={seconds}&exp={seconds}&nonce={nonce}&sub={subject}&email={email}&portal_access={true|false}
Building sso
- Take the exact UTF-8 bytes of the payload string.
- Stop if that is more than 4,096 bytes. The receiver refuses it.
- Encode the bytes as standard Base64, with padding.
- Do not use Base64url. Do not alter the result.
The receiver decodes sso, re-encodes the bytes as standard Base64, and requires
the result to equal the sso text it received.
Building sig
The key is the UTF-8 bytes of the secret string exactly as displayed. The secret happens to look like Base64url text — it is 32 random bytes encoded that way — but do not decode it. Decoding it produces a different key and every signature fails.
The message is the UTF-8 bytes of the sso text, not the payload and not the
raw bytes.
sig = lowercase_hex( HMAC_SHA256( key = UTF8(shared_secret_as_displayed),
message = UTF8(sso_text) ) )
The result is exactly 64 characters from 0-9 and a-f. Uppercase hexadecimal
is refused. The receiver compares in constant time.
The receiver checks the signature before it decodes the payload. A malformed payload with a wrong signature is reported as a signature failure, not a payload failure — not that you can see either (see Refusals).
Building the form
Form-encode exactly two fields:
sso={percent-encoded sso}&sig={hex signature}
The outer form encoding must percent-encode the +, /, and = characters that
standard Base64 produces. If your form encoder writes + unescaped, the receiver
decodes it as a space and the signature fails. Verify your encoder against this
before you test anything else.
Replay
The receiver stores the SHA-256 hash of the 32 nonce bytes under a unique constraint, in the same database transaction as the identity, grant, and audit changes for that handoff. Two requests carrying the same nonce cannot both succeed, including concurrently — the loser is classified as a replay and refused.
Stored nonce hashes are kept until the assertion's exp plus 60 seconds, then
pruned. This is not a shorter replay window: an assertion whose nonce record has
been pruned is already outside its freshness bounds and is refused on time.
Never retry an assertion. To try again, build a completely new payload with a new
iat, a new exp, and a new nonce, and sign it with the current secret.
Ordering and superseded assertions
The receiver keeps a watermark per configured provider and derived subject,
holding the last accepted iat and its portal_access value. Each accepted
assertion is compared against it under a row lock:
| Incoming | Result |
|---|---|
iat older than the watermark |
Refused as superseded; nothing changes |
iat equal, watermark false, incoming true |
Refused as superseded; nothing changes |
iat equal, watermark true, incoming false |
Accepted; watermark moves to false |
iat equal, same value as the watermark |
Accepted; watermark unchanged |
iat newer |
Accepted; watermark moves to the incoming value |
A denial wins at equal iat, in either arrival order. A genuinely newer true
restores access after an earlier false.
Use accurate, forward-moving issue times per customer. If eligibility drops to false, send a current false assertion when the customer next follows the Customer Area path. There is no webhook and no background revocation channel.
What the receiver does
Accepted, portal_access=true. The receiver maps or creates one
requester-only identity, creates or refreshes a server-side grant expiring 8
hours after the moment the request arrived, issues a session cookie with the same
expiry, and redirects the browser to /portal/tickets/new, the page titled
Service Catalog. The grant does not slide and the assertion's exp does not
affect it.
Accepted, portal_access=false. The receiver records the decision and the
nonce, revokes a current grant for that identity if one exists, and redirects to
the configured Customer Area return address. It creates no identity for an
unknown subject and issues no session. If the browser is currently signed in as
that same customer, that session is signed out.
Known subject whose user account is disabled. Refused. A disabled account is not reactivated by an eligible handoff.
Refused, any reason. HTTP 401, no body, no reason. The browser is not redirected anywhere.
Refusals
The receiver never tells the sender why an assertion failed. It records an internal reason code as a log warning on the receiver, and nothing else. Rejected assertions write no audit event and change no state — no identity, no grant, no nonce row, no watermark.
The warnings are rate-limited to one entry per reason code per minute. A burst of failures with the same cause produces one line, not one line per attempt, so the log does not show how many attempts were made.
These are the internal codes, so you can talk to an administrator reading the receiver's logs. You will not see them in a response.
| Code | Cause |
|---|---|
IntegrationUnavailable |
Not configured, not enabled, no return address, or no secret |
SecretUnavailable |
The stored secret could not be unprotected |
AudienceUnavailable |
The installation's public base address is missing or not valid HTTPS |
EnvelopeInvalid |
sso or sig missing, sso over 6,144 characters, or sig not 64 characters |
SignatureInvalid |
Non-hexadecimal characters in sig, or the signature does not match |
PayloadInvalid |
Not canonical Base64, over 4,096 decoded bytes, not valid UTF-8, wrong field count, wrong field name or position, or non-canonical percent-encoding |
VersionInvalid |
v is not 1 |
IssuerInvalid |
iss does not equal the expected issuer |
AudienceInvalid |
aud does not equal the expected audience |
TimestampInvalid |
iat or exp unparseable, or outside the lifetime and skew bounds |
NonceInvalid |
Not 32 bytes, or not canonical unpadded Base64url |
SubjectInvalid |
sub empty, over 256 characters, or containing a control character |
EmailInvalid |
email outside 3 to 320 characters, containing a control character, or not a parseable address |
EntitlementInvalid |
portal_access is not exactly true or false |
EntitlementSuperseded |
A newer or equal-time decision already stands |
ReplayDetected |
The nonce has been used |
InactiveIdentity |
The matching account is disabled |
OperationalFailure |
The receiver could not write the transaction |
Do not loop on a refusal, and do not infer from one whether an identity, a service, a grant, or a customer record exists.
Secret rotation
There is one current secret and no overlap window.
- A handoff arriving after rotation completes must be signed with the replacement.
- A handoff that already holds the receiver's configuration lock may commit against the former secret.
- A former-secret handoff cannot commit once rotation has completed.
- Rotation does not revoke existing grants.
- Disabling the integration refuses existing grants on their next request.
Do not send two signatures, a fallback secret, or an automatic retry with the old secret. Coordinate the cutover with the administrator; the procedure is in Customer Portal.
Sender conformance checklist
- [ ] Target is HTTPS at the fixed callback path, built from configuration.
- [ ] Request carries no query string, no files, and exactly two form fields.
- [ ] The nine inner fields are present, correctly named, and in order.
- [ ]
issandaudequal the displayed values byte for byte after decoding. - [ ]
iatandexpare digits only and satisfy the lifetime and skew bounds. - [ ]
nonceis 32 fresh random bytes, unpadded Base64url, never reused. - [ ]
subis stable, opaque, within 256 characters, and is not an email address. - [ ]
emailis currently provider-verified. - [ ]
portal_accessis exactly lowercasetrueorfalse. - [ ] Inner encoding round-trips through RFC 3986 percent-encoding unchanged,
with no
+for a space. - [ ]
ssois standard padded Base64 that round-trips unchanged, at most 4,096 decoded bytes. - [ ] The HMAC key is the UTF-8 bytes of the secret as displayed, not decoded.
- [ ]
sigis 64 lowercase hexadecimal characters over the exactssotext. - [ ] The outer form percent-encodes
+,/, and=insidesso. - [ ] No secret, payload, signature, nonce, subject, or email reaches logs or retained evidence.
If it does not work
- Every handoff is refused and nothing else changed. Check the secret first. A Base64url-decoded key is the most common cause and produces a plain 401 like every other failure.
- It works locally and fails through a proxy. Check that the form encoder
escapes
+insso, and thataudstill matches the displayed value. - The first handoff succeeds and the second is refused. Check that you generate a new nonce per assertion, not per session or per customer.
- A newer eligible handoff is refused. An equal-time or later denial already
stands. Issue a new assertion with a genuinely later
iat. - Handoffs stop working after an administrator action. Ask whether the secret was rotated or the integration disabled.
Test with synthetic identities. Record only case names, timestamps, and pass or fail — never actual protocol values.
Related pages
- Customer Portal — configuring and enabling the integration
- Audit Events
- Limits and scope
- Glossary