Avanta is a super-lightweight (sub 100kB) TypeScript-first OAuth library for teams who want prebuilt providers with full type safety and zero framework lock-in.
If you like the convenience of NextAuth or Lucia, but want something that:
- gives you full control over the OAuth flow (no magic, no sessions, no database)
- returns scope-aware typed data (request
emailandguilds? the return type includes exactly those fields) - uses only the Fetch API (works in Bun, Node 18+, Deno, Cloudflare Workers)
- has zero dependencies
Avanta is a good fit.
It gives you:
- 5 prebuilt providers: Discord, GitHub, Google, Microsoft, Twitch, and more to come
- Scope-level typing: the data you get back is typed to the exact scopes you requested
- Token management:
TokenStorewith built-in refresh, serialization, and expiry tracking - Scope-dependent actions: Discord's
guilds.joinandguilds.members.readare only available when you request those scopes
- Quickstart
- Providers
- TokenStore
- Common API
- Scope-dependent actions (Discord)
- Framework examples
- API reference
- Contributor docs
- License
Install:
bun add avantaOther package managers:
pnpm add avanta
npm i avantaEvery provider takes the same shape: clientId, clientSecret, redirectUri, and scopes. The scopes you pass in determine the return type of getData().
import Avanta from "avanta";
const discord = new Avanta.DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
redirectUri: "http://localhost:3000/auth/discord/callback",
scopes: ["identify", "email", "guilds"],
});Generate the authorization URL and redirect the user to it. You can optionally pass a state parameter for CSRF protection.
const url = discord.getOAuthUrl("random-state-string");
// Redirect the user to `url`When the user is redirected back, exchange the authorization code for a TokenStore:
const tokenStore = await discord.getTokenStore(code);The TokenStore holds the access token, refresh token, and expiry timestamp. You can serialize it to JSON for storage:
const serialized = tokenStore.compress(); // JSON stringAnd restore it later:
import { TokenStore } from "avanta"; // or from the util path
const restored = TokenStore.extract(serialized);Call getData() with the token store. The return type is automatically narrowed to the scopes you configured:
const data = await discord.getData(tokenStore);
// Because we requested ["identify", "email", "guilds"]:
data.username; // string (from "identify")
data.email; // string (from "email")
data.guilds; // Array<...> (from "guilds")
data.tokenStore; // TokenStore (always present, may be refreshed)If a scope wasn't requested, the field doesn't exist on the type — no runtime checks needed.
All providers share the same constructor shape and public API. The only differences are the available scopes and the shape of the returned data.
const discord = new Avanta.DiscordProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["identify", "email", "guilds", "connections"],
});Available scopes:
| Scope | Data returned |
|---|---|
identify |
id, username, avatar, discriminator, global_name, banner, accent_color, locale, mfa_enabled, premium_type, public_flags |
email |
email, verified |
guilds |
guilds (array of partial guild objects) |
connections |
connections (array of connection objects) |
guilds.join |
Enables actions.guilds.join(...) |
guilds.members.read |
Enables actions.guilds.members.read(...) |
gdm.join |
Group DM join capability |
role_connections.write |
Role connection metadata write |
dm_channels.read |
DM channel read access |
const github = new Avanta.GitHubProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["read:user", "user:email", "read:org"],
});Available scopes:
| Scope | Data returned |
|---|---|
read:user |
login, id, node_id, avatar_url, html_url, name, company, blog, location, bio, twitter_username, public_repos, public_gists, followers, following, created_at, updated_at |
user:email |
emails (array with email, primary, verified, visibility) |
read:org |
orgs (array with login, id, node_id, avatar_url, description) |
Note: GitHub only issues refresh tokens when token expiration is enabled on the GitHub App. If your app doesn't have token expiration,
refreshTokens()will throw.
const google = new Avanta.GoogleProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["openid", "profile", "email"],
});Available scopes:
| Scope | Data returned |
|---|---|
openid |
sub (stable Google account ID) |
profile |
name, given_name, family_name, picture, locale |
email |
email, email_verified |
Google's OAuth URL automatically requests offline access and consent prompt for refresh token support.
const microsoft = new Avanta.MicrosoftProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["User.Read", "Organization.Read.All", "offline_access"],
});Available scopes:
| Scope | Data returned |
|---|---|
User.Read |
id, displayName, givenName, surname, mail, userPrincipalName, jobTitle, officeLocation, businessPhones, mobilePhone, preferredLanguage, avatarUrl |
Organization.Read.All |
organization (object with id, displayName, address fields, verifiedDomains, or null for personal accounts) |
offline_access |
Enables refresh tokens (no data fields) |
Note: The
avatarUrlfield requires the access token to fetch the actual image bytes. Personal Microsoft accounts may havenull— useuserPrincipalNameas a fallback.
const twitch = new Avanta.TwitchProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["user:read:email"],
});Twitch always returns base profile data regardless of scopes:
| Field | Description |
|---|---|
id |
Unique Twitch numeric ID (as string) |
login |
Lowercase login name |
display_name |
Case-preserved display name |
type |
Account type ("", "admin", "staff", "global_mod") |
broadcaster_type |
"", "affiliate", or "partner" |
description |
Channel bio |
profile_image_url |
Profile image URL |
offline_image_url |
Offline stream image URL |
created_at |
ISO 8601 account creation date |
Additional scopes:
| Scope | Data returned |
|---|---|
user:read:email |
email |
TokenStore is a small utility class that holds OAuth tokens and manages serialization.
import Avanta from "avanta";
// Created automatically by getTokenStore()
const tokenStore = await provider.getTokenStore(code);
// Properties
tokenStore.access_token; // string
tokenStore.refresh_token; // string
tokenStore.access_token_expires_at; // number (ms since epoch)
// Serialize to JSON string (for database storage, cookies, etc.)
const json = tokenStore.compress();
// Restore from JSON string
const restored = TokenStore.extract(json);When you call getData(), Avanta automatically refreshes the access token if it has expired. The returned data.tokenStore may be a new instance with fresh tokens — always persist the returned token store.
const data = await discord.getData(tokenStore);
// IMPORTANT: persist the (possibly refreshed) token store
await db.user.update({
where: { id: userId },
data: { tokens: data.tokenStore.compress() },
});Every provider exposes the same methods:
| Method | Description |
|---|---|
getOAuthUrl(state?) |
Returns the full authorization URL. Pass an optional state string for CSRF protection. |
getTokens(code) |
Exchanges an authorization code for raw token data (provider-specific shape). |
getTokenStore(code) |
Exchanges a code for a TokenStore (recommended over getTokens). |
refreshTokens(tokenStore) |
Refreshes the access token using the refresh token. Returns raw token data. |
refreshTokenStore(tokenStore) |
Refreshes and returns a new TokenStore. |
getData(tokenStore) |
Fetches all user data for the configured scopes. Auto-refreshes expired tokens. |
Discord's provider exposes an actions object whose methods are typed based on the scopes you requested.
Requires the guilds.join scope and a bot token with CREATE_INSTANT_INVITE permission in the target guild.
const discord = new Avanta.DiscordProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["identify", "guilds.join"],
});
const result = await discord.actions.guilds.join({
guildId: "123456789",
tokenStore,
botToken: process.env.DISCORD_BOT_TOKEN!,
options: {
nick: "New Member",
roles: ["role-id-1"],
mute: false,
deaf: false,
},
});
// result.data is the guild member object (or null if already a member)
// result.tokenStore is the (possibly refreshed) token storeRequires the guilds.members.read scope.
const discord = new Avanta.DiscordProvider({
clientId: "...",
clientSecret: "...",
redirectUri: "...",
scopes: ["identify", "guilds.members.read"],
});
const result = await discord.actions.guilds.members.read({
guildId: "123456789",
tokenStore,
});
// result.data is the guild member objectIf you don't request these scopes, discord.actions won't have these methods at the type level — TypeScript prevents you from calling them.
// app/auth/discord/route.ts
import Avanta from "avanta";
const discord = new Avanta.DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
redirectUri: "http://localhost:3000/auth/discord/callback",
scopes: ["identify", "email"],
});
export async function GET() {
const url = discord.getOAuthUrl();
return Response.redirect(url);
}// app/auth/discord/callback/route.ts
export async function GET(req: Request) {
const url = new URL(req.url);
const code = url.searchParams.get("code")!;
const tokenStore = await discord.getTokenStore(code);
const data = await discord.getData(tokenStore);
// data.username, data.email, data.tokenStore
// Store in your database, set a session cookie, etc.
return Response.redirect("/dashboard");
}import Avanta from "avanta";
const github = new Avanta.GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectUri: "http://localhost:3000/auth/github/callback",
scopes: ["read:user", "user:email"],
});
// Redirect route
app.get("/auth/github", (req, res) => {
res.redirect(github.getOAuthUrl());
});
// Callback route
app.get("/auth/github/callback", async (req, res) => {
const code = req.query.code as string;
const tokenStore = await github.getTokenStore(code);
const data = await github.getData(tokenStore);
// data.login, data.emails, data.tokenStore
res.json({ user: data.login, emails: data.emails });
});The default export is an object containing all providers:
import {
DiscordProvider,
GitHubProvider,
GoogleProvider,
MicrosoftProvider,
TwitchProvider,
} from "avanta";All providers accept the same options:
{
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: [...Scope[]]; // const tuple — drives return type inference
}| Property / Method | Type | Description |
|---|---|---|
access_token |
string |
The current access token |
refresh_token |
string |
The refresh token (empty string if unavailable) |
access_token_expires_at |
number |
Expiry as ms since epoch |
compress() |
string |
JSON-serialized token data |
compressed |
string (getter) |
Alias for compress() |
TokenStore.extract(json) |
TokenStore (static) |
Deserialize from JSON string |
- Implementation + extension guide:
FOR_AGENTS.md
MIT
