Skip to main content

Authentication & Tokens

A deep dive into how the Oho REST API decides who you are and what you're allowed to touch: the bearer token you send on every request, how that token is scoped per entity type and operation, and how to rotate it safely. There are two ways to obtain that token — a personal access token you generate in the app, or an OAuth2 client for machine-to-machine integrations. The separate server-side oauth/jobadder and oauth/ncc endpoints that broker third-party integrations are a different thing again, covered in OAuth Integrations.

Start with the basics

API Basics covers the short version — base URLs, the Authorization header, and the error envelope. This page expands the Authentication section there into everything you need for production. The examples assume $OHO_BASE and $OHO_TOKEN are set:

export OHO_BASE="https://<tenant>.weareoho.com/openapi/v1"
export OHO_TOKEN="<your-access-token>"

The bearer token​

Every call to the API authenticates with a bearer token in the Authorization header. The header value must start with Bearer (the prefix is matched case-insensitively); without it the request is treated as unauthenticated:

curl -sS "$OHO_BASE/workers" \
-H "Authorization: Bearer $OHO_TOKEN"

The token is a signed JWT with a finite lifespan. You don't construct or sign it yourself — generate one from Settings → Access Tokens in the Oho app ("Access Tokens allow you to make programmatic requests to Oho's APIs"), copy it once, and store it as a secret in your own environment. (Deployments wired to an external OAuth identity provider can present a bearer token from that provider in the same header — but an Oho access token is the default.) A token inherits the privileges of the user who created it — it doesn't carry a separate, independently configured permission set. The server validates it on every request; a missing, malformed, or expired token is rejected by the authentication filter with 401 Unauthorized and the message Unauthorized to perform this action.

Treat the token like a password

Anyone holding the token can act as you, with all of your privileges. Don't commit it to source control, paste it into shared docs or tickets, or log it. Inject it from a secrets manager or an environment variable as shown above, and rotate immediately if it leaks (see Rotating a token).

Authenticate with OAuth2​

An access token is tied to the person who created it, which makes it a poor fit for a server-side integration: it carries an individual's name, and it dies with their account. For machine-to-machine callers — an HR or payroll system pushing workers into Oho on a schedule — register an OAuth2 client instead and exchange its credentials for a token.

Oho implements the OAuth2 client_credentials grant (RFC 6749 §4.4). It is the only supported grant type: there is no authorization-code, password, or implicit flow, and no user consent step.

Register a client​

Create the client from Settings → API Credentials in the Oho app. You get back a client_id (always prefixed oho_oauth_) and a client_secret.

The secret is shown once

Like an access token, the client_secret is revealed only at creation and cannot be retrieved afterwards. Store it in your secrets manager straight away. If you lose it, register a new client.

Exchange credentials for a token​

POST /oauth/token with a form-encoded body. Note this endpoint takes application/x-www-form-urlencoded, not JSON — it is the one endpoint in the API that does.

curl -sS -X POST "$OHO_BASE/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$OHO_CLIENT_ID" \
-d "client_secret=$OHO_CLIENT_SECRET"
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600
}

Credentials may also go in an Authorization: Basic header (client_secret_basic) rather than the body, if that's what your HTTP client does naturally:

curl -sS -X POST "$OHO_BASE/oauth/token" \
-u "$OHO_CLIENT_ID:$OHO_CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials"

When both are supplied, the body wins.

Use the token​

The access_token is an ordinary Oho bearer token. Send it exactly as you would a personal access token — every other endpoint on the API treats the two identically:

curl -sS "$OHO_BASE/workers?pageSize=1" \
-H "Authorization: Bearer $ACCESS_TOKEN"

Lifetime and renewal​

Tokens last one hour by default (expires_in, in seconds; your deployment may be configured differently — read the value rather than hard-coding 3600).

client_credentials has no refresh token (RFC 6749 §4.4.3). To renew, request a new token with the same client credentials. A sound pattern is to cache the token in memory, reuse it until shortly before expires_in elapses, and fetch a fresh one on expiry or on the first 401.

Errors​

StatuserrorCause
400unsupported_grant_typegrant_type was missing or was not client_credentials
401invalid_clientUnknown client_id, wrong client_secret, or a revoked client

The body follows the OAuth2 error shape — { "error": ..., "error_description": ... } — rather than Oho's usual { "error": { "code", "message", "requestId" } } envelope. A failed Basic attempt also carries a WWW-Authenticate: Basic realm="oho-oauth" header.

An OAuth2 token is a Super Admin token

Every OAuth2 client in a tenant mints tokens for the same shared service account, and that account holds the Super Admin role. This has two consequences worth planning around:

  • There is no per-client scoping. A client registered for a narrow purpose — say, pushing worker records — can read and write everything the API exposes. The per-entity-type authorization below still describes how the API decides, but a Super Admin passes every check.
  • The audit trail attributes the action to the service account, not to the client. Writes made through any OAuth2 client are indistinguishable from one another in the history.

Treat the client_secret with the same care as a Super Admin password, and prefer a personal access token belonging to a purpose-made, least-privilege user where you need the action attributed or the blast radius contained.

Scopes: per-entity-type authorization​

A valid token tells the server who you are. Authorization then decides what you can do, and it is enforced against your account's privileges per entity type combined with an operation — not per URL path. Internally each endpoint calls an isAPIAuthorizedEntityType(operation, entityType) check before doing any work, so two things have to line up for a request to succeed:

  1. Your account holds privileges for the entity type the endpoint operates on (worker, credential, and so on).
  2. Those privileges cover the operation that the HTTP method maps to on that entity.

HTTP methods map to operations consistently across the API:

HTTP methodOperation
GETREAD
POST (create)CREATE
PATCH / PUTUPDATE
DELETEDELETE

So an account that can read workers but not create them will succeed on GET /workers and be refused on POST /workers, even though both target the same entity type.

Entity types by endpoint group​

Each group of endpoints is guarded by one entity type. Privileges on one entity type grant nothing on another — an account authorized only for worker cannot read credentials.

EndpointsEntity typeOperations available
/workersworkerREAD, CREATE, UPDATE, DELETE
/credentialscredentialREAD, CREATE, UPDATE, DELETE
/recruitment-checkscredentialCheckREAD, CREATE, UPDATE, DELETE
/fetch-requestscaptureRequestREAD, CREATE, UPDATE, DELETE
/exemptionsexemptionREAD, CREATE, UPDATE, DELETE
/webhookswebhookREAD, CREATE, UPDATE, DELETE
/bansbanREAD only

/bans is a read-only surface — there is no write operation on the ban entity, so even an account with broad privileges only ever gets READ there. The table above covers the most-used endpoint groups; other surfaces (/applicants, /screening-packages, and more) follow the same entity-type + operation model, each guarded by its own entity type.

401 vs 403: telling the two apart​

The distinction between authentication and authorization shows up directly in the status code, and getting them straight saves debugging time:

StatusMeaningWhat to check
401The token is missing, malformed, or expiredIs the header present? Has the token been rotated out or expired?
403The token is valid, but the account lacks that entity-type / operation privilegeDoes the account behind the token have this entity type and this operation?

The 403 body names the operation that was refused in error.message. It also includes the caller's own account URN — the identity behind the token — so the message reads like:

403 — authenticated but not authorized
{
"error": {
"code": "FORBIDDEN",
"message": "urn:li:corpuser:jdoe is unauthorized to CREATE workers.",
"requestId": "01JD8Y2Q4K7N3M0P5R8T9V2W6X"
}
}

The urn:li:corpuser:... value identifies the calling account, not any worker or resource; resources are always addressed by their synthetic wkr_/cred_ IDs.

A 403 is never fixed by re-issuing the same token — the token works, the account behind it just lacks the privilege. Grant the missing entity-type / operation privilege to that account (via its role or access policies), or use a token from an account that already has it.

Least privilege

Because a token inherits its creator's privileges, scope access at the account level. Run an automated job under a service account whose privileges cover only what it needs — e.g. READ on worker and credential and nothing else. If that token leaks, the blast radius is a read-only data exposure rather than full write access across every entity type.

Rotating a token​

Tokens don't last forever, and any token that may have been exposed should be replaced immediately. Rotate without downtime by overlapping the old and new token rather than swapping in one step:

  1. Issue a new token in Settings → Access Tokens from the same account (it inherits that account's privileges, just like the one you're replacing). Both tokens are now valid at once.
  2. Roll out the new token to every caller — update the secret in your secrets manager, redeploy, and confirm traffic is flowing with the new value.
  3. Revoke the old token once nothing is using it. From that point a stale caller still on the old value gets 401.
# Sanity-check a freshly issued token against a cheap, read-only endpoint before rolling it out
curl -sS -o /dev/null -w "%{http_code}\n" "$OHO_BASE/workers?pageSize=1" \
-H "Authorization: Bearer $NEW_OHO_TOKEN" # expect 200
There is no recovery for a lost token

A token's value is shown once at creation. If you lose it, you can't retrieve it — issue a new one and revoke the lost one. This is the same one-time-reveal pattern used for webhook signing secrets.

OAuth proxy endpoints​

Not to be confused with Authenticate with OAuth2 above. The /oauth/jobadder/* and /oauth/ncc/* endpoints are a different mechanism again — they don't authenticate you, they let Oho broker credentials for third-party providers (JobAdder, NCC) on your behalf during integration setup. You will rarely call them directly. See OAuth Integrations for the JobAdder token exchange and the NCC police-check consent flow.

Inbound API auth is one half of trust; verifying that a webhook delivery genuinely came from Oho is the other. Each webhook carries an HMAC signing secret that Oho uses to sign outbound deliveries, and it rotates on its own endpoint, POST /webhooks/{id}/rotate. Like an access token, the new secret is returned once. To rotate without dropping deliveries, accept both the old and new secret during a changeover window (at least 24 hours) while you roll your verification code. See Webhooks & Event Delivery for the full delivery and verification model.

Where to go next​