# Signing in and creating a client

> Create an API client, sign in with a password, a browser, a device, or a token.

Source: https://docs.thalovant.com/developers/sdk-functions/signing-in/

---
Part of [SDK Functions](/developers/sdk-functions/). Every call below is shown in each language that has it.

## Create An API Client

Use this before hub discovery or identity provisioning.

  

```python
from thalovant import ThalovantControlPlane

api = ThalovantControlPlane()
```

  
  

```ts

const api = new ThalovantControlPlane();
```

  
  

```go

api := thalovant.NewDefaultControlPlane("")
```

  
  

```rust
use thalovant::ControlPlane;

let mut api = ControlPlane::default();
```

  

## Sign In

Sign in before private control-plane actions such as creating a client identity.

Accounts with multi-factor authentication enabled must include a TOTP code or a one-time recovery code; without one, the API rejects the sign-in with HTTP 401 and code `mfa_required`. Every SDK sends the MFA fields only when they are set.

  

```python
api.login("you@example.com", "password")

# MFA-enabled accounts add a TOTP code or a one-time recovery code:
api.login("you@example.com", "password", otp_code="123456")
api.login("you@example.com", "password", recovery_code="abcd-efgh-ijkl")
```

  
  

```ts
await api.login("you@example.com", "password");

// MFA-enabled accounts add a TOTP code or a one-time recovery code:
await api.login("you@example.com", "password", { otpCode: "123456" });
await api.login("you@example.com", "password", { recoveryCode: "abcd-efgh-ijkl" });
```

  
  

```go
_, err := api.Login(ctx, "you@example.com", "password", "")
if err != nil {
	log.Fatal(err)
}

// MFA-enabled accounts use LoginWithOptions with OTPCode or RecoveryCode:
_, err = api.LoginWithOptions(ctx, "you@example.com", "password", thalovant.LoginOptions{
	OTPCode: "123456",
})
if err != nil {
	log.Fatal(err)
}
```

  
  

```rust
api.login("you@example.com", "password", None).await?;

// MFA-enabled accounts use login_with_options with otp_code or recovery_code:
use thalovant::LoginOptions;

api.login_with_options(
    "you@example.com",
    "password",
    LoginOptions {
        otp_code: Some("123456".into()),
        ..Default::default()
    },
)
.await?;
```

  
  

```kotlin
api.login("you@example.com", "password")

// MFA-enabled accounts add a TOTP code or a one-time recovery code:
api.login("you@example.com", "password", otpCode = "123456")
api.login("you@example.com", "password", recoveryCode = "abcd-efgh-ijkl")
```

  
  

```swift
try await api.login(email: "you@example.com", password: "password")

// MFA-enabled accounts add a TOTP code or a one-time recovery code:
try await api.login(email: "you@example.com", password: "password", otpCode: "123456")
try await api.login(email: "you@example.com", password: "password", recoveryCode: "abcd-efgh-ijkl")
```

  
  

```csharp
await api.LoginAsync("you@example.com", "password");

// MFA-enabled accounts add a TOTP code or a one-time recovery code:
await api.LoginAsync("you@example.com", "password", otpCode: "123456");
await api.LoginAsync("you@example.com", "password", recoveryCode: "abcd-efgh-ijkl");
```

  

Common failure:

```text
Missing Thalovant API access token
```

Fix it by signing in before the private call, or by [passing an API token to the control-plane client](#authenticate-with-an-api-token). An HTTP 401 with code `mfa_required` means the account needs the TOTP or recovery code shown above.

## Sign In Without A Password

Use device login when the code should never see your password: CLIs, agents, notebooks, and machines you do not fully trust with account credentials.

The SDK prints a line like `To sign in, visit https://dash.thalovant.com/activate and enter the code XXXX-XXXX`, opens that page in your browser when it can, and polls the API while you approve the sign-in with your normal browser session, including Google sign-in or MFA. On approval the SDK receives a scoped, revocable API token and stores it exactly like a password login.

Approving a device sign-in needs a paid workspace plan; on a free plan the approval is rejected with HTTP 402. Device login needs Python 0.4.22, Node.js 0.2.25, Go v0.3.3, Rust v0.2.20, or Kotlin, Swift, and .NET 0.1.1.

  

```python
api.login_with_browser()

# Optional: request narrower scopes and label the token in the dashboard.
api.login_with_browser(scopes=["hubs:read", "clients:write"], client_name="my-cli")
```

  
  

```ts
// Prints: To sign in, visit https://dash.thalovant.com/activate and enter the code XXXX-XXXX
const token = await api.loginWithBrowser({ clientName: "my-laptop" });

// api.accessToken is now set, exactly like after api.login(...).
```

  
  

```go
token, err := api.LoginWithBrowser(ctx, thalovant.DeviceLoginOptions{
	Scopes:     []string{"hubs:read", "clients:write"}, // optional
	ClientName: "my-cli",                               // optional label in the dashboard
})
if err != nil {
	log.Fatal(err)
}
fmt.Println("signed in, token id:", token["token_id"])
```

  
  

```rust
use thalovant::DeviceLoginOptions;

let token = api
    .login_with_browser(DeviceLoginOptions {
        scopes: vec!["hubs:read".into(), "clients:write".into()],
        client_name: Some("my-tool".into()),
        ..Default::default()
    })
    .await?;
println!("signed in, token scopes: {}", token["scopes"]);
```

  
  

```kotlin

api.loginWithBrowser(
    DeviceLoginOptions(scopes = listOf("hubs:read"), clientName = "kotlin-demo"),
)
```

  
  

```swift
let token = try await api.loginWithBrowser(options: DeviceLoginOptions(
    scopes: ["hubs:read", "clients:write"],
    clientName: "my-macbook"
))
print(token.tokenId ?? "", token.scopes)
```

  
  

```csharp
var result = await api.LoginWithBrowserAsync(new DeviceLoginOptions
{
    Scopes = new[] { "hubs:read", "clients:write" },
    ClientName = "my-tool",
});
Console.WriteLine($"{result.TokenId} expires {result.ExpiresAt}");
```

  

Every SDK honors the same options: `scopes` to narrow what the token can do, `client_name` (native casing) to label the token in the dashboard, `open_browser` to skip opening the page, `prompt` to replace the printed line, and a timeout that defaults to 15 minutes.

The approving user manages the resulting token on the dashboard's [API Tokens page](/account/api-tokens/): scopes, expiry, last use, and revocation.

## Sign In From A Phone Or Desktop App

Use native sign-in when the code *is* the app the person is looking at: an Android or iOS app, or a desktop client. The app opens the browser, the person signs in with their normal session, and the browser hands the app back an authorization code — no password ever reaches your process, and no device code has to be typed.

Device login is the right choice for a CLI on a machine with no browser. Native sign-in is the right choice when the app can open one and receive a redirect.

The flow is OAuth 2.0 authorization code with PKCE ([RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636), [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252), [RFC 9700](https://datatracker.ietf.org/doc/html/rfc9700)), and it is three calls:

1. **Begin.** The SDK builds the authorization URL and hands back two secrets to keep: a `state` and a PKCE `verifier`.
2. **Open and wait.** The app opens that URL and waits for its registered redirect.
3. **Complete.** The app pulls the code out of the redirect and exchanges it, sending the verifier — which never entered the browser.

Native sign-in needs Python `0.7.6`, Node.js, Kotlin, Swift or .NET `0.7.2`, Go `v0.9.2`, or Rust `0.9.1`.

  

```python
from thalovant.native_auth import begin_native_sign_in

begun = begin_native_sign_in(
    client_id="my-app",
    redirect_uri="myapp://auth",
)
webbrowser.open(begun.authorization_url)

# ... the browser comes back to myapp://auth?code=...&state=...
code = begun.code_from(redirect)
if code:
    api.complete_native_sign_in(code, begun.verifier, "my-app", "myapp://auth")
```

  
  

```ts

const begun = await beginNativeSignIn({
  clientId: "my-app",
  redirectUri: "myapp://auth",
});
open(begun.authorizationUrl);

// ... the browser comes back to myapp://auth?code=...&state=...
const code = codeFrom(begun, redirect);
if (code) {
  await api.completeNativeSignIn(code, begun.verifier, "my-app", "myapp://auth");
}
```

`beginNativeSignIn` is async because it hashes the PKCE challenge with Web Crypto, which keeps the browser bundle working.

  
  

```go
begun, err := thalovant.BeginNativeSignIn(thalovant.NativeSignInOptions{
    ClientID:    "my-app",
    RedirectURI: "myapp://auth",
})
// open begun.AuthorizationURL

// ... the browser comes back to myapp://auth?code=...&state=...
if code, ok := begun.CodeFrom(redirect); ok {
    token, err := plane.CompleteNativeSignIn(ctx, code, begun.Verifier, "my-app", "myapp://auth")
}
```

  
  

```rust
use thalovant::native_auth::{begin_native_sign_in, NativeSignInOptions};

let begun = begin_native_sign_in(NativeSignInOptions {
    client_id: "my-app".into(),
    redirect_uri: "myapp://auth".into(),
    scopes: None,
    dashboard_url: None,
})?;
// open begun.authorization_url

// ... the browser comes back to myapp://auth?code=...&state=...
if let Some(code) = begun.code_from(&redirect) {
    plane.complete_native_sign_in(&code, &begun.verifier, "my-app", "myapp://auth").await?;
}
```

  
  

```kotlin
val begun = NativeSignIn.begin(
    clientId = "my-app",
    redirectUri = "myapp://auth",
)
// open begun.authorizationUrl

// ... the browser comes back to myapp://auth?code=...&state=...
val code = begun.codeFrom(redirect)
if (code != null) {
    plane.completeNativeSignIn(code, begun.verifier, "my-app", "myapp://auth")
}
```

  
  

```swift
let begun = try NativeSignIn.begin(
    clientID: "my-app",
    redirectURI: "myapp://auth"
)
// open begun.authorizationURL

// ... the browser comes back to myapp://auth?code=...&state=...
if let code = begun.code(from: redirect) {
    try await plane.completeNativeSignIn(
        code: code, verifier: begun.verifier,
        clientID: "my-app", redirectURI: "myapp://auth"
    )
}
```

  
  

```csharp
var begun = NativeSignIn.Begin(
    clientId: "my-app",
    redirectUri: "myapp://auth");
// open begun.AuthorizationUrl

// ... the browser comes back to myapp://auth?code=...&state=...
var code = begun.CodeFrom(redirect);
if (code is not null)
{
    await plane.CompleteNativeSignInAsync(code, begun.Verifier, "my-app", "myapp://auth");
}
```

  

The default scopes are `hubs:read`, `clients:read` and `clients:write` — enough for an app to list hubs and manage its own client identity. Pass `scopes` to narrow them.

### What the SDK refuses, and why

These are not suggestions the caller can opt out of. They are the reason the flow is safe on a device where another app may be listening for the same redirect.

- **`S256` only.** `plain` is refused by the API, and the SDKs do not offer it — there is no way to ask for the weaker challenge.
- **The verifier never enters the browser.** It goes to the token endpoint and nowhere else. That is what makes an intercepted code useless to whoever intercepted it.
- **The redirect must arrive where the attempt asked it to.** `state` proves the answer belongs to this request; the SDK also compares the redirect's scheme, host and path against the one the attempt registered. A callback that arrives at some other address is not this attempt's, however good its state looks.
- **A refusal that carries a code is still a refusal.** A redirect with `error=` returns no code, even when a `code=` is present alongside it.
- **The token exchange refuses cleartext.** Sending a verifier and code over plain HTTP would put both on the wire ([CWE-319](https://cwe.mitre.org/data/definitions/319.html)); the exchange requires HTTPS outside explicit loopback development endpoints.
- **The dashboard URL is validated.** It must be a Thalovant URL, and a URL carrying a fragment is refused — a fragment would put every parameter where a browser never sends it.

`code_from` (native casing) returns nothing rather than raising on a state mismatch, a missing code, or an `error=` response. All three mean "do not continue", and a caller that handles them alike cannot accidentally treat one as success.

**Do not retry a failed exchange with the same code.** A code presented twice revokes the token the first exchange minted (RFC 9700), so a retry destroys the token it is trying to obtain. Start again from the begin call.

## Authenticate With An API Token

Use a stored API token when the process should start authenticated, such as CI jobs, servers, and AI agent configs.

Mint a token on the dashboard's [API Tokens page](/account/api-tokens/) or through [device login](#sign-in-without-a-password), then pass it to the control-plane constructor. Tokens are scoped, revocable, and show their last use on that page; creating one needs a paid workspace plan. The examples read the token from a `THALOVANT_API_TOKEN` environment variable, the same convention the [MCP server](/developers/mcp/) uses.

  

```python

from thalovant import ThalovantControlPlane

api = ThalovantControlPlane(access_token=os.environ["THALOVANT_API_TOKEN"])
# Ready immediately; no login call needed.
```

  
  

```ts
const api = new ThalovantControlPlane("https://api.thalovant.com", {
  accessToken: process.env.THALOVANT_API_TOKEN,
});

// Ready immediately; no login call needed.
```

  
  

```go
api := thalovant.NewDefaultControlPlane(os.Getenv("THALOVANT_API_TOKEN"))
```

  
  

```rust
use thalovant::ControlPlane;

let token = std::env::var("THALOVANT_API_TOKEN").expect("THALOVANT_API_TOKEN is set");
let api = ControlPlane::with_access_token(token);
```

  
  

```kotlin
val api = ThalovantControlPlane(accessToken = System.getenv("THALOVANT_API_TOKEN"))
```

  
  

```swift
let api = ThalovantControlPlane(accessToken: ProcessInfo.processInfo.environment["THALOVANT_API_TOKEN"])
```

  
  

```csharp
var api = new ThalovantControlPlane(accessToken: Environment.GetEnvironmentVariable("THALOVANT_API_TOKEN"));
```

  

  Keep tokens out of source code and logs. Store them in a secret manager or CI secret, and revoke a token on the API Tokens page the moment it might have leaked.
