Product documentation
Conditional access policies for an application
Per-application, first-match-wins conditional-access policies: gate sign-in on authentication strength, risk level, source IP, and session age, and act with allow, deny, or step-up. Where to configure them in the console, the customer-plane API, and exactly how the hub evaluates them.
Conditional access policies for an application
A conditional-access policy decides what happens at sign-in for one OAuth application: let the user in, refuse them, or require a stronger authentication step first. The decision is made from the context of the sign-in itself — how strongly the user authenticated, the risk the platform scored, where the request came from, and how old the session is — not from a static allow-list.
Policies are configured per application and evaluated by the authorization hub on every sign-in for that application. This page covers what a policy is made of, where you set one, the API behind the console, and exactly how the hub picks a winner.
This gates access at the hub — the broker that issues tokens. It applies to every user of the application, whether they sign in with a Thoryn-hosted account or through a federated IdP (Entra ID, Okta, Google Workspace, SAML). It is a different control from identity-service's own risk-based step-up, which acts inside a Thoryn-hosted login.
How a policy is evaluated
The hub evaluates an application's policies at authorization time — after the user has authenticated and the upstream claims are known, and before the authorization code is issued — and again when a refresh token is exchanged. The algorithm is first-match-wins, fail-open:
- Load the application's enabled policies, ordered by
priorityascending (a lower number is considered first). - For each policy, test its conditions against the sign-in. A policy matches only
when every condition it declares is satisfied (
ANDacross conditions). A policy with no conditions matches every sign-in. - The first policy that matches wins; its action is applied and evaluation stops.
- If no policy matches — or the application has no policies at all — the sign-in is allowed. This is the deliberate fail-open default: conditional access adds friction, it is not the platform's authentication floor.
Because the first match wins, order your policies from most specific to most general and
put the ones that should short-circuit (a DENY) at a lower priority number.
Actions
A matched policy applies exactly one action:
| Action | Effect |
|---|---|
ALLOW | The sign-in proceeds. Use this to carve out an exception at a low priority that stops a later, broader DENY/STEP_UP from firing. |
DENY | The hub refuses the sign-in and returns access_denied to the application. |
STEP_UP | The sign-in must reach a higher authentication assurance — the ACR named in stepUpAcr — before access is granted; the hub asks the identity provider to step the user up. stepUpAcr is an ACR identifier your identity provider recognises (Thoryn-hosted logins use values such as urn:thoryn:acr:phishing-resistant); it is required for this action and ignored for the others. |
Conditions
Every condition field is optional. An omitted field places no condition on that axis; a policy with all fields omitted always matches. The axes are:
| Condition field | Values | Matches when… |
|---|---|---|
authStrength | password, mfa, phishing-resistant | the strength the user actually achieved (derived from the sign-in's amr) is below the level named — the session has not reached the bar. password < mfa < phishing-resistant. A sign-in with no amr counts as password (the weakest), so it is caught by any mfa or phishing-resistant bar. |
minimumRiskLevel | NONE, LOW, MEDIUM, HIGH | the risk the platform scored for the sign-in is at least the level named. |
ipRanges + ipRangeMode | CIDR strings; mode INSIDE_ALLOW or OUTSIDE_DENY | INSIDE_ALLOW: the source IP is inside one of the ranges. OUTSIDE_DENY: the source IP is outside every range. Mode defaults to INSIDE_ALLOW. |
maxSessionAgeSecs | seconds | the session is older than this many seconds since the original authentication (auth_time). |
reauthIntervalSecs | seconds | the time since the original authentication exceeds this interval. |
namedLocationIds | UUIDs | the source IP is inside a reusable named location's ranges (merged with any inline ipRanges, then evaluated under ipRangeMode). See the note under Named locations. |
The risk level is the same signal the risk-based step-up and geo-blocking controls use; the authentication strength reflects whether the user completed multi-factor authentication.
On the auth-strength axis.
authStrengthis an enforcement condition, the same polarity as the risk and session-age axes: it matches when the session is below the strength you name, so the action applies to sign-ins that have not reached the bar. Pair it withDENYto refuse sign-ins that are too weak (authStrength: mfablocks password-only), or withSTEP_UPto elevate anyone below a target strength (authStrength: phishing-resistantsteps up password and MFA sessions). A sign-in that already meets or exceeds the named strength does not match, so the policy leaves it alone.
Worked condition examples
Deny any sign-in the platform scored as high-risk:
{
"name": "Block high-risk sign-ins",
"priority": 10,
"action": "DENY",
"conditions": { "minimumRiskLevel": "HIGH" }
}Require a phishing-resistant step-up for medium-or-higher risk:
{
"name": "Step up on elevated risk",
"priority": 20,
"action": "STEP_UP",
"stepUpAcr": "urn:thoryn:acr:phishing-resistant",
"conditions": { "minimumRiskLevel": "MEDIUM" }
}Deny sign-ins from outside your corporate ranges:
{
"name": "Corporate network only",
"priority": 30,
"action": "DENY",
"conditions": {
"ipRanges": ["203.0.113.0/24", "198.51.100.0/24"],
"ipRangeMode": "OUTSIDE_DENY"
}
}Force re-authentication once a session passes an hour:
{
"name": "Re-auth hourly",
"priority": 40,
"action": "STEP_UP",
"stepUpAcr": "urn:thoryn:acr:mfa",
"conditions": { "maxSessionAgeSecs": 3600 }
}Block sign-ins that did not complete multi-factor authentication:
{
"name": "Block password-only sign-ins",
"priority": 15,
"action": "DENY",
"conditions": { "authStrength": "mfa" }
}This denies any session below MFA — a password-only login, or a sign-in whose IdP sent no
amr. A session that completed MFA (or a phishing-resistant method) is at or above the bar
and is allowed through.
Step up anyone who has not yet used a phishing-resistant method:
{
"name": "Step up to phishing-resistant",
"priority": 25,
"action": "STEP_UP",
"stepUpAcr": "urn:thoryn:acr:phishing-resistant",
"conditions": { "authStrength": "phishing-resistant" }
}Password and MFA sessions are below phishing-resistant, so they are stepped up; a session that already authenticated with a phishing-resistant method matches nothing here and proceeds.
Named locations
A policy may reference reusable named locations by id in namedLocationIds instead of
repeating CIDR ranges inline. A named location is a labelled group of CIDR ranges: define
"Corporate network" or "Datacenter egress" once, then reference it from any policy. When a
policy lists namedLocationIds, the hub merges those locations' ranges with any inline
ipRanges on the policy and evaluates them together under ipRangeMode.
Named locations have their own customer-plane surface at /api/v1/named-locations, scoped to
your tenant. They ride the same application scopes as policies (see Scopes),
so no extra permission is needed. Create the locations you want, then reference them by id.
| Method | Path | Description |
|---|---|---|
GET | /api/v1/named-locations | List your tenant's named locations. |
POST | /api/v1/named-locations | Create a named location. Returns 201 with its id. |
GET | /api/v1/named-locations/{id} | Read one named location. |
PATCH | /api/v1/named-locations/{id} | Update a named location. Omitted fields are left unchanged. |
DELETE | /api/v1/named-locations/{id} | Delete a named location. Returns 204. |
A location that is still referenced by a policy cannot be deleted: the delete returns 409
named_location_in_use. Remove the reference from every policy first, then delete the location.
A malformed CIDR is rejected with 422 invalid_cidr. The full request/response schemas are in
the generated NamedLocation API reference.
Create a named location, then a policy that uses it:
LOC_ID=$(curl -s -X POST https://api.thoryn.org/api/v1/named-locations \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Corporate network", "cidrs": ["203.0.113.0/24", "198.51.100.0/24"]}' | jq -r .id)
curl -X POST https://api.thoryn.org/api/v1/clients/$CLIENT_ID/access-policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\": \"Corporate network only\", \"action\": \"DENY\",
\"conditions\": {\"namedLocationIds\": [\"$LOC_ID\"], \"ipRangeMode\": \"OUTSIDE_DENY\"}}"Where to configure a policy
Console (recommended). Open the application under your workspace's Applications and go to its Conditional access section. Add a policy, choose its conditions and action, and set its priority. The console reads and writes the same customer-plane API described below, scoped to the application you are editing.
API / CLI (automation). Drive the endpoints directly for scripted or repeatable configuration — see the next section.
Endpoint reference
The policies for an application live under its client id. All calls go to product-api through the api-gateway and carry a tenant admin bearer token.
| Method | Path | Description |
|---|---|---|
GET | /api/v1/clients/{clientId}/access-policies | List the application's policies. |
POST | /api/v1/clients/{clientId}/access-policies | Create a policy. Returns 201 with the stored policy (including its id). |
GET | /api/v1/clients/{clientId}/access-policies/{id} | Read one policy. |
PATCH | /api/v1/clients/{clientId}/access-policies/{id} | Update a policy. Omitted fields are left unchanged. |
DELETE | /api/v1/clients/{clientId}/access-policies/{id} | Delete a policy. Returns 204. |
Scopes. Conditional access is per-application configuration, so it rides the existing
application scopes — there is no separate scope to grant. A read needs
tenant:applications.read or tenant:clients.read; a write needs
tenant:applications.write or tenant:clients.write. The console (which holds the
tenant:applications.* scopes) and the thoryn CLI (which holds tenant:clients.*) are
both admitted.
Create a policy:
curl -X POST https://api.thoryn.org/api/v1/clients/$CLIENT_ID/access-policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Block high-risk sign-ins",
"priority": 10,
"action": "DENY",
"conditions": { "minimumRiskLevel": "HIGH" }
}'The full request/response schemas are in the generated ConditionalAccessPolicy API reference.
Troubleshooting
A policy never seems to fire. Check its priority. An earlier (lower-number) policy
that matches wins and stops evaluation, so a broad ALLOW at priority 0 will mask
everything after it. Confirm the policy is enabled — disabled policies are skipped
entirely.
Everything is allowed even though I created a DENY. A policy matches only when
every condition is satisfied. A condition that never matches (e.g. an ipRanges that
never contains your test IP under INSIDE_ALLOW) means the policy as a whole never
matches, and with no match the fail-open default allows the sign-in.
A session-age policy does nothing for federated users. The session-age axis needs the
auth_time claim on the upstream token. A federation member that does not emit auth_time
makes the condition fail-open (it cannot detect a stale session), so the policy does not
fire for those users.
400 invalid_policy. The hub rejected the policy shape — an unknown action, an
authStrength/minimumRiskLevel/ipRangeMode value outside the accepted set, or a
malformed CIDR. The detail names the allowed values.
404 for a policy id. Either the id does not exist, or the application is not in your
tenant. Cross-tenant access returns 404, never 403 — there is no existence leak, so a
404 never confirms that some other tenant's application exists.
Security notes
- Fail-open is deliberate. No policy, or no match, allows the sign-in. Conditional access adds friction on top of authentication; it is not the authentication floor. Do not rely on it as the only barrier to a sensitive application — pair it with a strong login policy.
STEP_UPwithoutstepUpAcrdegrades to a deny. ASTEP_UPpolicy that reaches evaluation with no configured ACR cannot ask for anything, so the hub denies rather than silently allowing. Always setstepUpAcron aSTEP_UPpolicy.- Source IP honours trusted proxies only. The evaluator reads the client IP from
X-Forwarded-Foronly when the request came through a configured trusted proxy (thoryn.proxy.trusted-ips); otherwise it uses the direct peer address. An untrusted client cannot spoof its source IP through the header. - Tenant isolation holds. You can only address applications in your own tenant, and a
cross-tenant policy id is a
404.
Related
- Integrate an application — register the OAuth client whose policies you are configuring.
- Risk-based step-up on impossible travel — the identity-service risk control that feeds the risk axis for Thoryn-hosted sign-ins.
- Restrict logins by country — geo/IP controls at the login surface.
- Multi-factor authentication — what determines the authentication strength axis.
- ConditionalAccessPolicy API reference — generated request/response schemas.
- NamedLocation API reference — generated request/response schemas for the reusable named-location surface.