What this is about
For most operations it is enough that someone is signed in. Some ask for more: whoever approves a template or grants roles should perhaps have signed in with a second factor, or recently. A second factor is something besides the password, such as a code from an authenticator app or a security key.
There are two settings for this:
- The sign-in level says how strongly someone signed in.
- The maximum age of the sign-in says how long ago the sign-in may have happened at most.
If the sign-in is not enough for an operation, the backend answers with 401 and says exactly what is missing. The user interface then sends the person to Keycloak again, and Keycloak asks only for the step that is missing. This is called step-up: raising the sign-in by a level without starting over.
The three levels
| Level | What the person did | Example |
|---|---|---|
basic | signed in, by whatever means the realm allows | user name and password |
mfa | signed in with a second factor | password and one-time code (OTP) |
phishing-resistant | signed in with a factor that is bound to the address of the site | passkey, security key (WebAuthn) |
Phishing-resistant means: a fake site under a different address cannot do anything with this factor. The key only answers the real address.
The levels are ordered. A stronger sign-in satisfies every weaker requirement: whoever signed in phishing-resistant also satisfies mfa and basic.
The three levels are fixed. An installation cannot add a level of its own or rename one. A module writes “this needs mfa” once in its code, and that should mean the same in every installation. The names describe what the person did, not how an identity provider numbers it. That way they stay valid when the identity provider changes.
The maximum age
A requirement consists of a level and, if wanted, a maximum age. The maximum age is a whole number of seconds, at least one. A user interface cannot be told anything more precise.
The time of the sign-in is in the token, in the claim auth_time. A claim is a named value in the token. A requirement is satisfied when
- the person’s level satisfies the required level and
- with a maximum age set, the sign-in is not longer ago than the maximum age. Exactly the maximum age still counts.
A few examples:
| Requirement | Meaning |
|---|---|
basic, no maximum age | any sign-in. This is the normal case, and every signed-in person satisfies it |
basic, 300 seconds | “just signed in again”, by whatever method |
mfa, no maximum age | with a second factor, no matter when |
mfa, 300 seconds | with a second factor, and at most five minutes ago |
If the sign-in time is missing, no maximum age is satisfied. There is no tolerance for clocks that differ. If someone is refused by a narrow margin, they sign in again, get a fresh sign-in time and pass.
Where the level comes from
Keycloak calls the concept Level of Authentication. The level is in the token, in the claim acr. So that CIAS’s own names are there and not numbers, CIAS teaches them to Keycloak:
- CIAS writes its three names into the realm’s ACR-to-LoA mapping, the attribute
acr.loa.map:basic1,mfa2,phishing-resistant3. This happens in every reconciliation. Names an operator added there stay. - Keycloak then writes exactly these names into
acr:basicafter a fresh sign-in,mfaafter a step-up tomfa. A token refresh keeps the level. - Which factors Keycloak asks for is set by the operator in the realm’s browser flow. This is the sign-in sequence Keycloak works through at login. For every level above
basicit needs a step with the condition “Condition – Level of Authentication” and the level’s number. CIAS never builds or changes this flow. It only reads which levels it can reach.
At login, the user interface asks for a level with acr_values. If the backend asks for a maximum age, it also sends prompt=login: Keycloak then signs in anew in any case, and the sign-in time is fresh. If the browser flow has no step for a level, Keycloak silently ignores acr_values and issues a token at the level the flow has. The backend then refuses again. So that a person is not the first to notice, the reconciliation compares the required levels with the reachable ones and reports every one that is missing. See Reconciliation with Keycloak and The Keycloak adapter.
basic when in doubt
CIAS reads the level so that a mistake never grants more. This is called fail-closed, see Reject when in doubt.
acr in the token | auth_time in the token | What CIAS reads |
|---|---|---|
basic, mfa or phishing-resistant | – | exactly this level |
| missing or empty | – | basic |
any other value, such as 0, 2 or a URL | – | basic. CIAS writes a warning to the log once per value, not on every request |
| – | a number | the sign-in time |
| – | missing or not a number | no sign-in time. No maximum age is satisfied |
Keycloak writes 0 into acr, for example, when a session was only restored from a cookie. CIAS reads that as basic too.
The presented token counts
On every request CIAS exchanges the token for one for the backend. CIAS reads level and sign-in time, however, from the token the caller presented, that is, before the exchange.
The reason: the presented token is the one the person signed in for. Its acr is the result of the sign-in sequence the user interface asked for. Keycloak’s token exchange does not keep the level: the exchanged token of a person who just stepped up to mfa reads basic again. If CIAS read the exchanged token, every step-up would be lost on the way into the backend.
In the RequestContext both values are authenticationLevel and authenticatedAt. The RequestContext is the store for the details of a request, which CDMS and the other modules read from.
User switch
With a user switch, a permitted person works on behalf of another. Level and sign-in time do not switch with it. They describe the person at the keyboard, not the person being acted as.
An administrator who switches into a person who just signed in with a second factor does not inherit that sign-in. If an operation requires mfa, the administrator must have signed in with a second factor.
Service accounts
A service account is the account a service signs in with by itself, without a person, see Sign in as a service. CIAS recognizes its token by azp (the client the token was issued for) and preferred_username in the form service-account-<client>, both from the presented token.
A service account always has the level basic and no sign-in time, because its token contains neither. It also cannot sign in again. It could never satisfy a requirement above basic or with a maximum age.
Whether a service account is exempt from a requirement is decided by each module:
| Where | Service accounts |
|---|---|
| the administration of CIAS | exempt, see Sign-in level for the administration |
| CDMS | exempt, see Sign-in level per model and operation |
| final documents in CRMS | exempt, see Sign-in level for documents |
| approvals in CRMS | not exempt. An approval is always given by a person, see Approval chains |
The answer when the sign-in is not enough
sequenceDiagram
participant O as User interface (BFF)
participant B as Backend
participant K as Keycloak
O->>B: Request with token (acr = basic)
B->>B: Permission present? yes
B->>B: Sign-in enough? no, mfa is required
B-->>O: 401, step-up-required, acr_values="mfa"
O->>K: Login with acr_values=mfa
K-->>O: asks only for the second factor, new token (acr = mfa)
O->>B: the same request again
B-->>O: 200
The backend here is CDMS, CRMS or CIAS itself. How the user interfaces react, with dialog, window and exactly one repeat, is described in Catching up on a stronger sign-in (step-up).
The answer has two parts: a header following the standard RFC 9470, which any client can understand, and a body in the platform’s error format, which the user interfaces translate.
POST /cias/admin/role-assignments
Authorization: Bearer eyJ… (acr = basic)HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", error_description="A stronger or more recent sign-in is required", acr_values="mfa", max_age=300
{ "messageKey": "cias.authentication.step-up-required|mfa|300", "code": "401", "layer": "api" }The key carries its values after |:
cias.authentication.step-up-required|<level>
cias.authentication.step-up-required|<level>|<seconds>
- The first value is the required level.
- The second is the maximum age in seconds. Without a maximum age it is missing, and the header has no
max_age.
Header and body always say the same, because CIAS reads the header from the key the module wrote. No module writes the header itself. That way the header states exactly the requirement that was checked.
Two kinds of 401
cias.authentication.step-up-required- The token is valid.
- The sign-in is too weak or too old.
- Header:
error="insufficient_user_authentication"withacr_valuesand, where set,max_age. - Signing in again helps: with the required level, then repeat the request.
cias.authentication.token-rejected- The token is invalid, foreign or refused by the token exchange.
- Header:
error="invalid_token". - Signing in with more factors does not help.
- See What happens with the token on every request.
A client tells the two answers apart by the value of error in the header.
Permission first, then the sign-in
Every module first checks whether the person may do the operation at all. Only then does it check whether the sign-in is enough.
| Permission present? | Sign-in enough? | Answer |
|---|---|---|
| no | – | the module's 403, as without a requirement |
| yes | no | 401 cias.authentication.step-up-required|… |
| yes | yes | the operation runs |
The other way round, CIAS would send a person to a stronger sign-in that would still not let them in afterwards.
Requirements are fixed
Where a requirement applies is stated by the module at the point where it decides on business grounds, never per URL:
- CDMS per model and operation, see Sign-in level per model and operation.
- CRMS per stage of an approval, and for reading and changing final documents, per kind of document.
- CIAS itself per area of its administration, see Sign-in level for the administration.
Every module declares the levels it requires in its declaration. That way the reconciliation can check whether Keycloak reaches them.
Where an installation sets a requirement, the value is fixed at build or rollout time: as a setting that an environment variable can override. No requirement can be changed at runtime, neither through an endpoint nor through a user interface. Whoever could lower a requirement could switch the step-up off.